outwrite 0.1.0a1__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outwrite contributors
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 @@
1
+ include examples/multiprocessing_demo.py
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: outwrite
3
+ Version: 0.1.0a1
4
+ Summary: Python logging and a live status line, across threads and processes.
5
+ License-Expression: MIT
6
+ Project-URL: Source, https://github.com/ariel-m-s/outwrite
7
+ Project-URL: Issues, https://github.com/ariel-m-s/outwrite/issues
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: System :: Logging
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: rich<15,>=14
20
+ Dynamic: license-file
21
+
22
+ # Outwrite
23
+
24
+ **Standard Python logging and one live status line, across threads and processes.**
25
+
26
+ Outwrite connects Python's logging module to [Rich](https://github.com/Textualize/rich)
27
+ and relays worker output to one renderer in the parent process. Use your existing
28
+ processes and ordinary loggers. No queue management in application code.
29
+
30
+ This is an early alpha. APIs may change; it is not production-certified.
31
+ Python 3.11+ is required. Rich is the only runtime dependency.
32
+
33
+ ## Installation
34
+
35
+ The first release is a prerelease:
36
+
37
+ ```sh
38
+ python -m pip install outwrite==0.1.0a1
39
+ ```
40
+
41
+ Or use `python -m pip install --pre outwrite` to allow the latest prerelease.
42
+ These commands become available once the release is published to PyPI.
43
+
44
+ To install from a local checkout:
45
+
46
+ ```sh
47
+ python -m pip install .
48
+ ```
49
+
50
+ ## One process
51
+
52
+ ```python
53
+ import logging
54
+ from outwrite import setup_output, status
55
+
56
+ logger = logging.getLogger("my_app")
57
+
58
+ with setup_output(logger=logger) as output:
59
+ logger.info("Starting")
60
+ status.update("Processing files")
61
+ # Do some work.
62
+ status.clear()
63
+ logger.info("Done")
64
+ ```
65
+
66
+ ## Worker processes
67
+
68
+ Pass the connection returned by the parent to each worker. Call the **same**
69
+ setup function inside each worker:
70
+
71
+ ```python
72
+ import logging
73
+ import multiprocessing as mp
74
+ import signal
75
+ from outwrite import setup_output, status
76
+
77
+ logger = logging.getLogger("my_app")
78
+
79
+ def work(output, stop):
80
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
81
+ setup_output(output)
82
+ logger.info("Worker started")
83
+ for index in range(10):
84
+ if stop.is_set():
85
+ break
86
+ status.update(f"Processing file {index + 1}")
87
+ stop.wait(0.1)
88
+
89
+ if __name__ == "__main__":
90
+ ctx = mp.get_context("spawn")
91
+ stop = ctx.Event()
92
+ with setup_output(logger=logger, mp_context=ctx) as output:
93
+ process = ctx.Process(target=work, args=(output, stop))
94
+ process.start()
95
+ try:
96
+ process.join()
97
+ finally:
98
+ stop.set()
99
+ process.join()
100
+ ```
101
+
102
+ Always join workers and logging threads **inside** the parent context. On exit,
103
+ Outwrite drains queued messages, clears the status, and releases its resources.
104
+ Exit cleanup is also registered as a fallback; it does not stop or join workers
105
+ for you. Forced process termination can lose messages or damage multiprocessing
106
+ queues, so prefer a cooperative worker stop in real applications.
107
+
108
+ See [the runnable example](https://github.com/ariel-m-s/outwrite/blob/main/examples/multiprocessing_demo.py).
109
+
110
+ ## API
111
+
112
+ - `setup_output(logger=logger, level=logging.INFO, show_status=True, mp_context=ctx)`
113
+ starts parent output and returns an `OutputConnection` context handle.
114
+ - `setup_output(output)` connects a worker. The parent's logger name and status
115
+ setting are carried by the connection; keyword options are for parent setup.
116
+ - `status.update(text)` replaces the status line. `None` or `""` clears it.
117
+ - `status.clear()` clears the line without ending the session.
118
+
119
+ Status calls are safe from threads. Only one session and one status line are
120
+ active per process; concurrent writers replace the same line. Status calls before
121
+ setup or after parent cleanup raise `RuntimeError`.
122
+
123
+ The console uses stderr. Status is automatically disabled outside terminals;
124
+ logs still appear. `show_status=False` disables only status, and `level` controls
125
+ only the Rich console handler. Use integer logging levels, such as `logging.DEBUG`.
126
+
127
+ ## Logging behavior
128
+
129
+ Only the selected **named logger** and its propagating descendants are routed.
130
+ The root logger and unrelated loggers are not configured. Child loggers with
131
+ their own handlers or `propagate=False` retain their normal logging behavior.
132
+
133
+ Existing handlers on the selected logger also receive records in the parent.
134
+ For example, attach a standard `logging.FileHandler` before setup to retain file
135
+ logging. Outwrite does not close your handlers. It adds a Rich console handler,
136
+ so remove an existing console handler yourself if you do not want duplicate
137
+ console output. The logger's original handlers, level, and propagation are
138
+ restored when the parent context exits.
139
+
140
+ Records pass through standard `QueueHandler` preparation: exception tracebacks
141
+ become message text. Extra record attributes must be picklable. Cross-process
142
+ delivery is asynchronous; use a cooperative worker shutdown and join workers to
143
+ flush their messages. Ordering between different producers is not guaranteed.
144
+
145
+ The default queue context is `spawn`. If using `forkserver`, pass that same
146
+ context to setup and use it to create processes. `fork` is deliberately unsupported
147
+ because the parent has an active rendering thread. Do not send a connection to
148
+ an already-running pool; pass it when creating workers or through a pool initializer.
149
+
150
+ Rendering failures are reported to stderr and raised when the parent context
151
+ exits. Standard logging handlers retain their usual error-reporting behavior.
152
+ No arbitrary stdout/stderr capture is performed.
153
+
154
+ ## Why not just Rich?
155
+
156
+ Rich already supplies the columns, colors, links, and live display. Outwrite adds
157
+ the connection between existing worker processes, standard logging, and one live
158
+ status line. It is not a new renderer, process pool, or progress-bar framework.
159
+
160
+ ## Development
161
+
162
+ ```sh
163
+ python -m pip install -e .
164
+ python examples/multiprocessing_demo.py
165
+ python -m pip install build
166
+ python -m build
167
+ ```
168
+
169
+ No persisted test suite is included in this initial iteration. Add regression
170
+ tests before a production release, particularly for process shutdown and terminal
171
+ cleanup.
172
+
173
+ The initial wheel was verified on macOS ARM64 with Python 3.13.7 and 3.14.0,
174
+ including spawn/forkserver workers and real-terminal Ctrl+C cleanup. Python
175
+ 3.11/3.12 and other operating systems have not yet been runtime-tested.
176
+
177
+ ## License
178
+
179
+ MIT. The initial implementation adapts output coordination developed in Firefly,
180
+ with its author's permission.
181
+
182
+ ## Publishing
183
+
184
+ Maintainers use PyPI Trusted Publishing; no permanent upload token is stored in
185
+ GitHub. The GitHub environment `pypi` requires approval before publication.
186
+
187
+ 1. Update the version in `pyproject.toml`, and commit and push the change.
188
+ 2. Create a GitHub release with a matching tag, such as `v0.1.0a1`.
189
+ Mark alpha/beta/RC versions as prereleases.
190
+ 3. Review the build job and approve the `pypi` environment deployment.
191
+ 4. Verify the uploaded version on PyPI and install it in a fresh environment.
192
+
193
+ The workflow builds and validates the distributions before publishing. Release
194
+ files cannot be replaced on PyPI; use a new version to correct a release.
195
+ The initial pending publisher must be configured on PyPI for repository
196
+ `ariel-m-s/outwrite`, workflow `publish.yml`, and environment `pypi`.
@@ -0,0 +1,175 @@
1
+ # Outwrite
2
+
3
+ **Standard Python logging and one live status line, across threads and processes.**
4
+
5
+ Outwrite connects Python's logging module to [Rich](https://github.com/Textualize/rich)
6
+ and relays worker output to one renderer in the parent process. Use your existing
7
+ processes and ordinary loggers. No queue management in application code.
8
+
9
+ This is an early alpha. APIs may change; it is not production-certified.
10
+ Python 3.11+ is required. Rich is the only runtime dependency.
11
+
12
+ ## Installation
13
+
14
+ The first release is a prerelease:
15
+
16
+ ```sh
17
+ python -m pip install outwrite==0.1.0a1
18
+ ```
19
+
20
+ Or use `python -m pip install --pre outwrite` to allow the latest prerelease.
21
+ These commands become available once the release is published to PyPI.
22
+
23
+ To install from a local checkout:
24
+
25
+ ```sh
26
+ python -m pip install .
27
+ ```
28
+
29
+ ## One process
30
+
31
+ ```python
32
+ import logging
33
+ from outwrite import setup_output, status
34
+
35
+ logger = logging.getLogger("my_app")
36
+
37
+ with setup_output(logger=logger) as output:
38
+ logger.info("Starting")
39
+ status.update("Processing files")
40
+ # Do some work.
41
+ status.clear()
42
+ logger.info("Done")
43
+ ```
44
+
45
+ ## Worker processes
46
+
47
+ Pass the connection returned by the parent to each worker. Call the **same**
48
+ setup function inside each worker:
49
+
50
+ ```python
51
+ import logging
52
+ import multiprocessing as mp
53
+ import signal
54
+ from outwrite import setup_output, status
55
+
56
+ logger = logging.getLogger("my_app")
57
+
58
+ def work(output, stop):
59
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
60
+ setup_output(output)
61
+ logger.info("Worker started")
62
+ for index in range(10):
63
+ if stop.is_set():
64
+ break
65
+ status.update(f"Processing file {index + 1}")
66
+ stop.wait(0.1)
67
+
68
+ if __name__ == "__main__":
69
+ ctx = mp.get_context("spawn")
70
+ stop = ctx.Event()
71
+ with setup_output(logger=logger, mp_context=ctx) as output:
72
+ process = ctx.Process(target=work, args=(output, stop))
73
+ process.start()
74
+ try:
75
+ process.join()
76
+ finally:
77
+ stop.set()
78
+ process.join()
79
+ ```
80
+
81
+ Always join workers and logging threads **inside** the parent context. On exit,
82
+ Outwrite drains queued messages, clears the status, and releases its resources.
83
+ Exit cleanup is also registered as a fallback; it does not stop or join workers
84
+ for you. Forced process termination can lose messages or damage multiprocessing
85
+ queues, so prefer a cooperative worker stop in real applications.
86
+
87
+ See [the runnable example](https://github.com/ariel-m-s/outwrite/blob/main/examples/multiprocessing_demo.py).
88
+
89
+ ## API
90
+
91
+ - `setup_output(logger=logger, level=logging.INFO, show_status=True, mp_context=ctx)`
92
+ starts parent output and returns an `OutputConnection` context handle.
93
+ - `setup_output(output)` connects a worker. The parent's logger name and status
94
+ setting are carried by the connection; keyword options are for parent setup.
95
+ - `status.update(text)` replaces the status line. `None` or `""` clears it.
96
+ - `status.clear()` clears the line without ending the session.
97
+
98
+ Status calls are safe from threads. Only one session and one status line are
99
+ active per process; concurrent writers replace the same line. Status calls before
100
+ setup or after parent cleanup raise `RuntimeError`.
101
+
102
+ The console uses stderr. Status is automatically disabled outside terminals;
103
+ logs still appear. `show_status=False` disables only status, and `level` controls
104
+ only the Rich console handler. Use integer logging levels, such as `logging.DEBUG`.
105
+
106
+ ## Logging behavior
107
+
108
+ Only the selected **named logger** and its propagating descendants are routed.
109
+ The root logger and unrelated loggers are not configured. Child loggers with
110
+ their own handlers or `propagate=False` retain their normal logging behavior.
111
+
112
+ Existing handlers on the selected logger also receive records in the parent.
113
+ For example, attach a standard `logging.FileHandler` before setup to retain file
114
+ logging. Outwrite does not close your handlers. It adds a Rich console handler,
115
+ so remove an existing console handler yourself if you do not want duplicate
116
+ console output. The logger's original handlers, level, and propagation are
117
+ restored when the parent context exits.
118
+
119
+ Records pass through standard `QueueHandler` preparation: exception tracebacks
120
+ become message text. Extra record attributes must be picklable. Cross-process
121
+ delivery is asynchronous; use a cooperative worker shutdown and join workers to
122
+ flush their messages. Ordering between different producers is not guaranteed.
123
+
124
+ The default queue context is `spawn`. If using `forkserver`, pass that same
125
+ context to setup and use it to create processes. `fork` is deliberately unsupported
126
+ because the parent has an active rendering thread. Do not send a connection to
127
+ an already-running pool; pass it when creating workers or through a pool initializer.
128
+
129
+ Rendering failures are reported to stderr and raised when the parent context
130
+ exits. Standard logging handlers retain their usual error-reporting behavior.
131
+ No arbitrary stdout/stderr capture is performed.
132
+
133
+ ## Why not just Rich?
134
+
135
+ Rich already supplies the columns, colors, links, and live display. Outwrite adds
136
+ the connection between existing worker processes, standard logging, and one live
137
+ status line. It is not a new renderer, process pool, or progress-bar framework.
138
+
139
+ ## Development
140
+
141
+ ```sh
142
+ python -m pip install -e .
143
+ python examples/multiprocessing_demo.py
144
+ python -m pip install build
145
+ python -m build
146
+ ```
147
+
148
+ No persisted test suite is included in this initial iteration. Add regression
149
+ tests before a production release, particularly for process shutdown and terminal
150
+ cleanup.
151
+
152
+ The initial wheel was verified on macOS ARM64 with Python 3.13.7 and 3.14.0,
153
+ including spawn/forkserver workers and real-terminal Ctrl+C cleanup. Python
154
+ 3.11/3.12 and other operating systems have not yet been runtime-tested.
155
+
156
+ ## License
157
+
158
+ MIT. The initial implementation adapts output coordination developed in Firefly,
159
+ with its author's permission.
160
+
161
+ ## Publishing
162
+
163
+ Maintainers use PyPI Trusted Publishing; no permanent upload token is stored in
164
+ GitHub. The GitHub environment `pypi` requires approval before publication.
165
+
166
+ 1. Update the version in `pyproject.toml`, and commit and push the change.
167
+ 2. Create a GitHub release with a matching tag, such as `v0.1.0a1`.
168
+ Mark alpha/beta/RC versions as prereleases.
169
+ 3. Review the build job and approve the `pypi` environment deployment.
170
+ 4. Verify the uploaded version on PyPI and install it in a fresh environment.
171
+
172
+ The workflow builds and validates the distributions before publishing. Release
173
+ files cannot be replaced on PyPI; use a new version to correct a release.
174
+ The initial pending publisher must be configured on PyPI for repository
175
+ `ariel-m-s/outwrite`, workflow `publish.yml`, and environment `pypi`.
@@ -0,0 +1,39 @@
1
+ """Run with: python examples/multiprocessing_demo.py"""
2
+
3
+ import logging
4
+ import multiprocessing as mp
5
+ import signal
6
+ from multiprocessing.synchronize import Event
7
+
8
+ from outwrite import OutputConnection, setup_output, status
9
+
10
+ logger = logging.getLogger("demo")
11
+
12
+
13
+ def worker(output: OutputConnection, stop: Event) -> None:
14
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
15
+ setup_output(output)
16
+ for index in range(10):
17
+ if stop.is_set():
18
+ break
19
+ status.update(f"Processing file {index + 1} of 10")
20
+ if index % 3 == 0:
21
+ logger.info("Finished a batch")
22
+ stop.wait(0.2)
23
+ status.clear()
24
+ logger.info("Worker finished")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ context = mp.get_context("spawn")
29
+ stop = context.Event()
30
+ with setup_output(logger=logger, mp_context=context) as output:
31
+ process = context.Process(target=worker, args=(output, stop))
32
+ process.start()
33
+ try:
34
+ process.join()
35
+ finally:
36
+ stop.set()
37
+ process.join()
38
+ if process.exitcode != 0:
39
+ raise SystemExit(f"Worker exited with code {process.exitcode}")
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "outwrite"
7
+ version = "0.1.0a1"
8
+ description = "Python logging and a live status line, across threads and processes."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["rich>=14,<15"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Topic :: System :: Logging",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [project.urls]
26
+ Source = "https://github.com/ariel-m-s/outwrite"
27
+ Issues = "https://github.com/ariel-m-s/outwrite/issues"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ outwrite = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Standard logging and one live status line, across processes."""
2
+
3
+ from ._output import OutputConnection, setup_output, status
4
+
5
+ __all__ = ["OutputConnection", "setup_output", "status"]
@@ -0,0 +1,221 @@
1
+ """Parent rendering and worker connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ import multiprocessing as mp
8
+ import os
9
+ import sys
10
+ import threading
11
+ import traceback
12
+ from dataclasses import dataclass
13
+ from logging.handlers import QueueHandler
14
+ from multiprocessing.context import BaseContext
15
+ from multiprocessing.queues import Queue
16
+ from types import TracebackType
17
+
18
+ from rich.console import Console
19
+ from rich.live import Live
20
+ from rich.logging import RichHandler
21
+ from rich.text import Text
22
+
23
+ _connection: OutputConnection | None = None
24
+ _session: _OutputSession | None = None
25
+
26
+
27
+ class _Status:
28
+ """Send text to the active session's status line."""
29
+
30
+ def update(self, text: str | None) -> None:
31
+ """Replace the status text. None or an empty string clears it."""
32
+ if text is not None and not isinstance(text, str):
33
+ raise TypeError("Status text must be a string or None.")
34
+ if _connection is None:
35
+ raise RuntimeError("Call setup_output() before updating status.")
36
+ if _connection._show_status:
37
+ _connection._queue.put("" if text is None else text)
38
+
39
+ def clear(self) -> None:
40
+ """Clear the status line without ending the output session."""
41
+ self.update(None)
42
+
43
+
44
+ status = _Status()
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class OutputConnection:
49
+ """Pass this handle to workers; use it as a context manager in the parent."""
50
+
51
+ _queue: Queue
52
+ _logger_name: str
53
+ _show_status: bool
54
+ _parent_pid: int
55
+
56
+ def __enter__(self) -> OutputConnection:
57
+ if os.getpid() != self._parent_pid:
58
+ raise RuntimeError(
59
+ "Only the parent owns the output context; use setup_output(output) in workers."
60
+ )
61
+ if _session is None or _session.connection is not self:
62
+ raise RuntimeError("This output session is no longer active.")
63
+ if _session.entered:
64
+ raise RuntimeError("An output context cannot be nested.")
65
+ _session.entered = True
66
+ return self
67
+
68
+ def __exit__(
69
+ self,
70
+ exc_type: type[BaseException] | None,
71
+ exc_value: BaseException | None,
72
+ exc_traceback: TracebackType | None,
73
+ ) -> None:
74
+ if _session is None or _session.connection is not self:
75
+ raise RuntimeError("This output session is no longer active.")
76
+ _session.close()
77
+
78
+
79
+ class _OutputSession:
80
+ """Own everything that must remain in the rendering process."""
81
+
82
+ def __init__(
83
+ self,
84
+ connection: OutputConnection,
85
+ logger: logging.Logger,
86
+ console: Console,
87
+ level: int,
88
+ ) -> None:
89
+ self.connection = connection
90
+ self.logger = logger
91
+ self.console = console
92
+ self.previous_handlers = logger.handlers[:]
93
+ self.previous_level = logger.level
94
+ self.previous_propagate = logger.propagate
95
+ self.console_handler = RichHandler(console=console, level=level)
96
+ self.handlers = [*self.previous_handlers, self.console_handler]
97
+ self.queue_handler = QueueHandler(connection._queue)
98
+ self.live = Live(
99
+ console=console,
100
+ transient=True,
101
+ auto_refresh=False,
102
+ redirect_stdout=False,
103
+ redirect_stderr=False,
104
+ )
105
+ self.error: Exception | None = None
106
+ self.entered = False
107
+ self.listener = threading.Thread(
108
+ target=self.listen, name="outwrite-renderer", daemon=True
109
+ )
110
+
111
+ def listen(self) -> None:
112
+ while True:
113
+ try:
114
+ message = self.connection._queue.get()
115
+ except (EOFError, OSError) as error:
116
+ self.error = error
117
+ traceback.print_exception(error, file=sys.stderr)
118
+ return
119
+ if message is None:
120
+ return
121
+ try:
122
+ if isinstance(message, logging.LogRecord):
123
+ for handler in self.handlers:
124
+ if message.levelno >= handler.level:
125
+ handler.handle(message)
126
+ elif not message:
127
+ self.live.stop()
128
+ else:
129
+ text = Text(
130
+ message, style="cyan", no_wrap=True, overflow="ellipsis"
131
+ )
132
+ text.truncate(max(1, self.console.width - 2), overflow="ellipsis")
133
+ self.live.update(text, refresh=self.live.is_started)
134
+ if not self.live.is_started:
135
+ self.live.start(refresh=True)
136
+ except Exception as error:
137
+ # Keep draining so producers cannot deadlock behind a failed renderer.
138
+ self.error = error
139
+ traceback.print_exception(error, file=sys.stderr)
140
+
141
+ def close(self) -> None:
142
+ global _connection, _session
143
+ self.logger.handlers = self.previous_handlers
144
+ self.logger.setLevel(self.previous_level)
145
+ self.logger.propagate = self.previous_propagate
146
+ _connection = None
147
+ try:
148
+ self.connection._queue.put(None)
149
+ self.listener.join()
150
+ finally:
151
+ self.live.stop()
152
+ self.connection._queue.close()
153
+ self.connection._queue.join_thread()
154
+ self.queue_handler.close()
155
+ self.console_handler.close()
156
+ _session = None
157
+ atexit.unregister(_cleanup_at_exit)
158
+ if self.error is not None:
159
+ raise RuntimeError(
160
+ "Outwrite could not render one or more messages."
161
+ ) from self.error
162
+
163
+
164
+ def setup_output(
165
+ connection: OutputConnection | None = None,
166
+ *,
167
+ logger: logging.Logger | None = None,
168
+ level: int = logging.INFO,
169
+ show_status: bool = True,
170
+ mp_context: BaseContext | None = None,
171
+ ) -> OutputConnection:
172
+ """Start a parent session, or connect a worker to the parent's session.
173
+
174
+ Parent usage: ``with setup_output(logger=my_logger) as output: ...``.
175
+ Join workers and logging threads before leaving the block.
176
+ Worker usage: ``setup_output(output)`` before logging or updating status.
177
+ Worker configuration comes from the connection, not the keyword options.
178
+ """
179
+ global _connection, _session
180
+ if _connection is not None:
181
+ raise RuntimeError("Output is already configured in this process.")
182
+ if connection is not None:
183
+ if connection._parent_pid == os.getpid():
184
+ raise RuntimeError(
185
+ "The parent is already responsible for rendering this connection."
186
+ )
187
+ worker_logger = logging.getLogger(connection._logger_name)
188
+ worker_logger.handlers = [QueueHandler(connection._queue)]
189
+ worker_logger.setLevel(logging.DEBUG)
190
+ worker_logger.propagate = False
191
+ _connection = connection
192
+ return connection
193
+
194
+ if logger is None:
195
+ logger = logging.getLogger("outwrite")
196
+ if logger is logging.getLogger():
197
+ raise ValueError("Choose a named logger, not the root logger.")
198
+ if not isinstance(level, int):
199
+ raise TypeError("level must be an integer logging level, such as logging.INFO.")
200
+ context = mp_context if mp_context is not None else mp.get_context("spawn")
201
+ if context.get_start_method() == "fork":
202
+ raise ValueError("Use a spawn or forkserver multiprocessing context, not fork.")
203
+
204
+ console = Console(stderr=True)
205
+ connection = OutputConnection(
206
+ context.Queue(), logger.name, show_status and console.is_terminal, os.getpid()
207
+ )
208
+ session = _OutputSession(connection, logger, console, level)
209
+ logger.handlers = [session.queue_handler]
210
+ logger.setLevel(logging.DEBUG)
211
+ logger.propagate = False
212
+ _connection = connection
213
+ _session = session
214
+ session.listener.start()
215
+ atexit.register(_cleanup_at_exit)
216
+ return connection
217
+
218
+
219
+ def _cleanup_at_exit() -> None:
220
+ if _session is not None:
221
+ _session.close()
File without changes
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: outwrite
3
+ Version: 0.1.0a1
4
+ Summary: Python logging and a live status line, across threads and processes.
5
+ License-Expression: MIT
6
+ Project-URL: Source, https://github.com/ariel-m-s/outwrite
7
+ Project-URL: Issues, https://github.com/ariel-m-s/outwrite/issues
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: System :: Logging
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: rich<15,>=14
20
+ Dynamic: license-file
21
+
22
+ # Outwrite
23
+
24
+ **Standard Python logging and one live status line, across threads and processes.**
25
+
26
+ Outwrite connects Python's logging module to [Rich](https://github.com/Textualize/rich)
27
+ and relays worker output to one renderer in the parent process. Use your existing
28
+ processes and ordinary loggers. No queue management in application code.
29
+
30
+ This is an early alpha. APIs may change; it is not production-certified.
31
+ Python 3.11+ is required. Rich is the only runtime dependency.
32
+
33
+ ## Installation
34
+
35
+ The first release is a prerelease:
36
+
37
+ ```sh
38
+ python -m pip install outwrite==0.1.0a1
39
+ ```
40
+
41
+ Or use `python -m pip install --pre outwrite` to allow the latest prerelease.
42
+ These commands become available once the release is published to PyPI.
43
+
44
+ To install from a local checkout:
45
+
46
+ ```sh
47
+ python -m pip install .
48
+ ```
49
+
50
+ ## One process
51
+
52
+ ```python
53
+ import logging
54
+ from outwrite import setup_output, status
55
+
56
+ logger = logging.getLogger("my_app")
57
+
58
+ with setup_output(logger=logger) as output:
59
+ logger.info("Starting")
60
+ status.update("Processing files")
61
+ # Do some work.
62
+ status.clear()
63
+ logger.info("Done")
64
+ ```
65
+
66
+ ## Worker processes
67
+
68
+ Pass the connection returned by the parent to each worker. Call the **same**
69
+ setup function inside each worker:
70
+
71
+ ```python
72
+ import logging
73
+ import multiprocessing as mp
74
+ import signal
75
+ from outwrite import setup_output, status
76
+
77
+ logger = logging.getLogger("my_app")
78
+
79
+ def work(output, stop):
80
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
81
+ setup_output(output)
82
+ logger.info("Worker started")
83
+ for index in range(10):
84
+ if stop.is_set():
85
+ break
86
+ status.update(f"Processing file {index + 1}")
87
+ stop.wait(0.1)
88
+
89
+ if __name__ == "__main__":
90
+ ctx = mp.get_context("spawn")
91
+ stop = ctx.Event()
92
+ with setup_output(logger=logger, mp_context=ctx) as output:
93
+ process = ctx.Process(target=work, args=(output, stop))
94
+ process.start()
95
+ try:
96
+ process.join()
97
+ finally:
98
+ stop.set()
99
+ process.join()
100
+ ```
101
+
102
+ Always join workers and logging threads **inside** the parent context. On exit,
103
+ Outwrite drains queued messages, clears the status, and releases its resources.
104
+ Exit cleanup is also registered as a fallback; it does not stop or join workers
105
+ for you. Forced process termination can lose messages or damage multiprocessing
106
+ queues, so prefer a cooperative worker stop in real applications.
107
+
108
+ See [the runnable example](https://github.com/ariel-m-s/outwrite/blob/main/examples/multiprocessing_demo.py).
109
+
110
+ ## API
111
+
112
+ - `setup_output(logger=logger, level=logging.INFO, show_status=True, mp_context=ctx)`
113
+ starts parent output and returns an `OutputConnection` context handle.
114
+ - `setup_output(output)` connects a worker. The parent's logger name and status
115
+ setting are carried by the connection; keyword options are for parent setup.
116
+ - `status.update(text)` replaces the status line. `None` or `""` clears it.
117
+ - `status.clear()` clears the line without ending the session.
118
+
119
+ Status calls are safe from threads. Only one session and one status line are
120
+ active per process; concurrent writers replace the same line. Status calls before
121
+ setup or after parent cleanup raise `RuntimeError`.
122
+
123
+ The console uses stderr. Status is automatically disabled outside terminals;
124
+ logs still appear. `show_status=False` disables only status, and `level` controls
125
+ only the Rich console handler. Use integer logging levels, such as `logging.DEBUG`.
126
+
127
+ ## Logging behavior
128
+
129
+ Only the selected **named logger** and its propagating descendants are routed.
130
+ The root logger and unrelated loggers are not configured. Child loggers with
131
+ their own handlers or `propagate=False` retain their normal logging behavior.
132
+
133
+ Existing handlers on the selected logger also receive records in the parent.
134
+ For example, attach a standard `logging.FileHandler` before setup to retain file
135
+ logging. Outwrite does not close your handlers. It adds a Rich console handler,
136
+ so remove an existing console handler yourself if you do not want duplicate
137
+ console output. The logger's original handlers, level, and propagation are
138
+ restored when the parent context exits.
139
+
140
+ Records pass through standard `QueueHandler` preparation: exception tracebacks
141
+ become message text. Extra record attributes must be picklable. Cross-process
142
+ delivery is asynchronous; use a cooperative worker shutdown and join workers to
143
+ flush their messages. Ordering between different producers is not guaranteed.
144
+
145
+ The default queue context is `spawn`. If using `forkserver`, pass that same
146
+ context to setup and use it to create processes. `fork` is deliberately unsupported
147
+ because the parent has an active rendering thread. Do not send a connection to
148
+ an already-running pool; pass it when creating workers or through a pool initializer.
149
+
150
+ Rendering failures are reported to stderr and raised when the parent context
151
+ exits. Standard logging handlers retain their usual error-reporting behavior.
152
+ No arbitrary stdout/stderr capture is performed.
153
+
154
+ ## Why not just Rich?
155
+
156
+ Rich already supplies the columns, colors, links, and live display. Outwrite adds
157
+ the connection between existing worker processes, standard logging, and one live
158
+ status line. It is not a new renderer, process pool, or progress-bar framework.
159
+
160
+ ## Development
161
+
162
+ ```sh
163
+ python -m pip install -e .
164
+ python examples/multiprocessing_demo.py
165
+ python -m pip install build
166
+ python -m build
167
+ ```
168
+
169
+ No persisted test suite is included in this initial iteration. Add regression
170
+ tests before a production release, particularly for process shutdown and terminal
171
+ cleanup.
172
+
173
+ The initial wheel was verified on macOS ARM64 with Python 3.13.7 and 3.14.0,
174
+ including spawn/forkserver workers and real-terminal Ctrl+C cleanup. Python
175
+ 3.11/3.12 and other operating systems have not yet been runtime-tested.
176
+
177
+ ## License
178
+
179
+ MIT. The initial implementation adapts output coordination developed in Firefly,
180
+ with its author's permission.
181
+
182
+ ## Publishing
183
+
184
+ Maintainers use PyPI Trusted Publishing; no permanent upload token is stored in
185
+ GitHub. The GitHub environment `pypi` requires approval before publication.
186
+
187
+ 1. Update the version in `pyproject.toml`, and commit and push the change.
188
+ 2. Create a GitHub release with a matching tag, such as `v0.1.0a1`.
189
+ Mark alpha/beta/RC versions as prereleases.
190
+ 3. Review the build job and approve the `pypi` environment deployment.
191
+ 4. Verify the uploaded version on PyPI and install it in a fresh environment.
192
+
193
+ The workflow builds and validates the distributions before publishing. Release
194
+ files cannot be replaced on PyPI; use a new version to correct a release.
195
+ The initial pending publisher must be configured on PyPI for repository
196
+ `ariel-m-s/outwrite`, workflow `publish.yml`, and environment `pypi`.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ examples/multiprocessing_demo.py
6
+ src/outwrite/__init__.py
7
+ src/outwrite/_output.py
8
+ src/outwrite/py.typed
9
+ src/outwrite.egg-info/PKG-INFO
10
+ src/outwrite.egg-info/SOURCES.txt
11
+ src/outwrite.egg-info/dependency_links.txt
12
+ src/outwrite.egg-info/requires.txt
13
+ src/outwrite.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ rich<15,>=14
@@ -0,0 +1 @@
1
+ outwrite