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.
@@ -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
@@ -0,0 +1,5 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ .pytest_cache/
4
+ uv.lock
5
+ /dist/
@@ -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.
@@ -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,4 @@
1
+ from runcorder._session import instrument, session
2
+ from runcorder._context import context
3
+
4
+ __all__ = ["instrument", "session", "context"]
@@ -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