runcorder 0.5.1__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.
- runcorder-0.5.1/.github/workflows/publish.yml +44 -0
- runcorder-0.5.1/.gitignore +5 -0
- runcorder-0.5.1/LICENSE +21 -0
- runcorder-0.5.1/PKG-INFO +72 -0
- runcorder-0.5.1/README.md +29 -0
- runcorder-0.5.1/docs/user.md +168 -0
- runcorder-0.5.1/pyproject.toml +47 -0
- runcorder-0.5.1/src/runcorder/__init__.py +4 -0
- runcorder-0.5.1/src/runcorder/__main__.py +45 -0
- runcorder-0.5.1/src/runcorder/_capture.py +38 -0
- runcorder-0.5.1/src/runcorder/_context.py +49 -0
- runcorder-0.5.1/src/runcorder/_display.py +147 -0
- runcorder-0.5.1/src/runcorder/_frames.py +72 -0
- runcorder-0.5.1/src/runcorder/_location.py +86 -0
- runcorder-0.5.1/src/runcorder/_report.py +299 -0
- runcorder-0.5.1/src/runcorder/_session.py +274 -0
- runcorder-0.5.1/src/runcorder/_tracker.py +69 -0
- runcorder-0.5.1/src/runcorder/cli.py +68 -0
- runcorder-0.5.1/src/runcorder/watch.py +369 -0
- runcorder-0.5.1/tests/example1.py +118 -0
- runcorder-0.5.1/tests/test_capture.py +108 -0
- runcorder-0.5.1/tests/test_cli.py +151 -0
- runcorder-0.5.1/tests/test_context.py +95 -0
- runcorder-0.5.1/tests/test_display.py +53 -0
- runcorder-0.5.1/tests/test_location.py +149 -0
- runcorder-0.5.1/tests/test_placeholder.py +2 -0
- runcorder-0.5.1/tests/test_report.py +491 -0
- runcorder-0.5.1/tests/test_session.py +424 -0
- runcorder-0.5.1/tests/test_tracker.py +101 -0
- runcorder-0.5.1/tests/test_watch.py +318 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v5
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
run: uv python install
|
|
19
|
+
|
|
20
|
+
- name: Run tests
|
|
21
|
+
run: uv run pytest
|
|
22
|
+
|
|
23
|
+
- name: Build
|
|
24
|
+
run: uv build
|
|
25
|
+
|
|
26
|
+
- uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: pypi
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write # required for trusted publishing
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/download-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
- name: Publish to PyPI
|
|
44
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
runcorder-0.5.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sasha Ovsankin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
runcorder-0.5.1/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runcorder
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Always-on flight recorder for Python scripts: live watch line while it runs, compact Markdown report when it crashes or gets stuck.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SashaOv/runcorder
|
|
6
|
+
Project-URL: Source, https://github.com/SashaOv/runcorder
|
|
7
|
+
Project-URL: Documentation, https://github.com/SashaOv/runcorder/blob/main/docs/user.md
|
|
8
|
+
Author: Sasha Ovsankin
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Sasha Ovsankin
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
39
|
+
Classifier: Topic :: System :: Logging
|
|
40
|
+
Requires-Python: >=3.13
|
|
41
|
+
Requires-Dist: cyclopts>=3
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# Runcorder
|
|
45
|
+
|
|
46
|
+
An always-on flight recorder for Python scripts: live watch line while it runs, compact Markdown report when it crashes or gets stuck.
|
|
47
|
+
|
|
48
|
+
See [docs/user.md](docs/user.md).
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
Runcorder uses [uv](https://docs.astral.sh/uv/) for environment management. Requires Python 3.13+.
|
|
53
|
+
|
|
54
|
+
### Set up
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv sync
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Test
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run pytest
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Build
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv build
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Artifacts land in `dist/` (wheel and sdist).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Runcorder
|
|
2
|
+
|
|
3
|
+
An always-on flight recorder for Python scripts: live watch line while it runs, compact Markdown report when it crashes or gets stuck.
|
|
4
|
+
|
|
5
|
+
See [docs/user.md](docs/user.md).
|
|
6
|
+
|
|
7
|
+
## Development
|
|
8
|
+
|
|
9
|
+
Runcorder uses [uv](https://docs.astral.sh/uv/) for environment management. Requires Python 3.13+.
|
|
10
|
+
|
|
11
|
+
### Set up
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv sync
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Test
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv run pytest
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Build
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Artifacts land in `dist/` (wheel and sdist).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Runcorder User Manual
|
|
2
|
+
|
|
3
|
+
## Why Runcorder
|
|
4
|
+
|
|
5
|
+
Runcorder is an always-on flight recorder for yout Python scripts. While your script runs it shows a live watch line — elapsed time, custom context, and the current call chain — so you can see what the script is actually doing instead of staring at a silent terminal. If the script crashes or gets stuck, Runcorder writes a compact Markdown report with the filtered traceback, recent watch snapshots, and the surrounding run context.
|
|
6
|
+
|
|
7
|
+
It sits in the gap between a raw traceback and a full tracing system. There is no setup cost beyond adding it to the command line, it stays out of your way on successful runs, and the failure artifact is designed to paste straight into an intelligent-tool workflow without extra cleanup. Start with Runcorder on every script; escalate to deeper tracing only when you need it.
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
### Install from PyPI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install runcorder
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Runcorder requires Python 3.13 or newer.
|
|
18
|
+
|
|
19
|
+
### Run a script
|
|
20
|
+
|
|
21
|
+
No code changes required — just run your script under Runcorder:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python -m runcorder my_script.py --arg1 --arg2
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The script runs as if you had launched it directly with `python my_script.py`, except that a watch line appears on stderr while it runs and a report is written on failure.
|
|
28
|
+
|
|
29
|
+
### Add context (optional)
|
|
30
|
+
|
|
31
|
+
Inside your script, call `runcorder.context(...)` to surface variables on the live watch line and in the final report:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import runcorder
|
|
35
|
+
|
|
36
|
+
for epoch in range(10):
|
|
37
|
+
runcorder.context(epoch=epoch, loss=current_loss)
|
|
38
|
+
train_one_epoch()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Explicit integration
|
|
42
|
+
|
|
43
|
+
If you prefer to instrument from inside the program rather than using the CLI wrapper:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import runcorder
|
|
47
|
+
|
|
48
|
+
@runcorder.instrument
|
|
49
|
+
def main():
|
|
50
|
+
run_pipeline()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
or as a scoped context manager:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
with runcorder.session(tail=True):
|
|
57
|
+
run_pipeline()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Find and clean reports
|
|
61
|
+
|
|
62
|
+
Reports land in `~/.cache/runcorder/logs/YYMMDD-HHMMSS.md` by default (platform cache dir on systems without `~/.cache`). The path is printed to stderr the first time a report is written.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
runcorder clean # delete reports older than 1 day
|
|
66
|
+
runcorder clean 7d # older than 7 days
|
|
67
|
+
runcorder clean 12h # older than 12 hours
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API Reference
|
|
71
|
+
|
|
72
|
+
### `python -m runcorder <script.py> [args...]`
|
|
73
|
+
|
|
74
|
+
Run `<script.py>` in the same interpreter with Runcorder instrumentation installed before user code executes. Extra arguments are forwarded to the script.
|
|
75
|
+
|
|
76
|
+
Exit behavior:
|
|
77
|
+
|
|
78
|
+
- script exits normally → exit status `0`
|
|
79
|
+
- script raises `SystemExit` → that status is propagated
|
|
80
|
+
- any other uncaught exception → report is written, non-zero exit
|
|
81
|
+
|
|
82
|
+
### `runcorder clean [AGE]`
|
|
83
|
+
|
|
84
|
+
Delete reports older than `AGE` from the default log directory. `AGE` is a positive integer followed by `d` (days), `h` (hours), or `m` (minutes). Default: `1d`.
|
|
85
|
+
|
|
86
|
+
### `runcorder.session(**options)`
|
|
87
|
+
|
|
88
|
+
Return a context manager that records a session for the enclosed block:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
with runcorder.session(output="report.md", tail=True, watch_interval=3.0):
|
|
92
|
+
run_pipeline()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `runcorder.instrument`
|
|
96
|
+
|
|
97
|
+
Decorator form of `session()`. Supports both bare and keyword forms:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@runcorder.instrument
|
|
101
|
+
def main(): ...
|
|
102
|
+
|
|
103
|
+
@runcorder.instrument(output="run.md", tail=True)
|
|
104
|
+
def main(): ...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `runcorder.context(**kwargs)`
|
|
108
|
+
|
|
109
|
+
Set or update session-level key/value pairs. Keys are additive across calls; pass `None` to remove a key. The current context renders as `key=value` pairs on every watch line and is attached to each watch snapshot in the report. Called outside an active session, it warns once and is otherwise a no-op.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
runcorder.context(epoch=5, loss=0.312)
|
|
113
|
+
runcorder.context(loss=None) # remove "loss"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Session options
|
|
117
|
+
|
|
118
|
+
Shared by `session()` and `instrument`:
|
|
119
|
+
|
|
120
|
+
| Option | Default | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `output` | auto-named | Report path. When unset, Runcorder writes to `~/.cache/runcorder/logs/YYMMDD-HHMMSS.md`. Applied only when a report is actually emitted. |
|
|
123
|
+
| `tail` | `False` | Buffer stdout/stderr and include a rolling tail in the report. |
|
|
124
|
+
| `watch_interval` | `3.0` | Seconds between stack samples. Minimum `0.5`. |
|
|
125
|
+
| `watch_inplace` | `True` | Rewrite the previous status line in place when no foreign output has appeared and the stderr sink supports in-place updates. Set `False` for native code or subprocesses that write to the terminal. |
|
|
126
|
+
| `stuck_timeout` | `30.0` | Seconds of unchanged stack before a stuck notice is emitted and a snapshot is captured. Set `0` to disable. |
|
|
127
|
+
| `short_traceback` | `True` | Replace Python's default traceback with a concise two-line notice pointing to the report. Set `False` to keep the full traceback on stderr alongside the report. |
|
|
128
|
+
|
|
129
|
+
When `short_traceback=True` (the default), an uncaught exception prints:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
ExceptionType: message
|
|
133
|
+
[runcorder] see report at <path>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The full traceback is always preserved in the report.
|
|
137
|
+
|
|
138
|
+
### Integration with logging
|
|
139
|
+
|
|
140
|
+
Runcorder messages are written using standard Python logging, with one exception: the watch line uses direct stderr writes when `watch_inplace=True` and the process is running interactively on a TTY.
|
|
141
|
+
|
|
142
|
+
When the watch line is emitted via logging, Runcorder only logs it when the line has changed from the previous sample. Runcorder does not modify logging settings.
|
|
143
|
+
|
|
144
|
+
### When a report is written
|
|
145
|
+
|
|
146
|
+
A report is produced only when one of these happens:
|
|
147
|
+
|
|
148
|
+
- the run exits via an uncaught exception
|
|
149
|
+
- stuck detection fires
|
|
150
|
+
|
|
151
|
+
Successful runs produce no report, even when `output` is set. On the first write, Runcorder prints `[runcorder] report is written to <path>` to stderr.
|
|
152
|
+
|
|
153
|
+
### Report format
|
|
154
|
+
|
|
155
|
+
A Markdown file with a YAML front matter block followed by sections:
|
|
156
|
+
|
|
157
|
+
- **Front matter** — `command`, `cwd`, `python`, `started_at`.
|
|
158
|
+
- **Stuck snapshot** — filtered stack at the moment stuck was detected (when present).
|
|
159
|
+
- **Exception** — type, message, and filtered traceback (on failure). Each stack frame includes function arguments with their `repr()` values at capture time.
|
|
160
|
+
- **Watch snapshots** — recent status lines with context variables.
|
|
161
|
+
- **Output tail** — combined stdout/stderr tail (only when `tail=True`).
|
|
162
|
+
- **Summary** — `ended_at`, `duration_s`, `exit_status` (integer or `"exception"`). Absent if the process is killed before the session finishes.
|
|
163
|
+
|
|
164
|
+
### Limitations
|
|
165
|
+
|
|
166
|
+
- Watch samples the main thread only; no C-extension or subprocess frames.
|
|
167
|
+
- Stream tracking is best-effort: native writes that bypass Python stream objects are not detected.
|
|
168
|
+
- Not a TUI — the single-line display degrades to append-only output on non-interactive or non-rewritable sinks (redirected logs, notebook cells, batch-system log collectors).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runcorder"
|
|
7
|
+
version = "0.5.1"
|
|
8
|
+
description = "Always-on flight recorder for Python scripts: live watch line while it runs, compact Markdown report when it crashes or gets stuck."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Sasha Ovsankin" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Debuggers",
|
|
24
|
+
"Topic :: System :: Logging",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"cyclopts>=3",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/SashaOv/runcorder"
|
|
32
|
+
Source = "https://github.com/SashaOv/runcorder"
|
|
33
|
+
Documentation = "https://github.com/SashaOv/runcorder/blob/main/docs/user.md"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
runcorder = "runcorder.cli:app"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/runcorder"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
]
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|