2# +==== BEGIN rotary_logger =================+
4# ..........####...####..........
5# ......###.....#.#########......
6# ....##........#.###########....
7# ...#..........#.############...
8# ...#..........#.#####.######...
9# ..#.....##....#.###..#...####..
10# .#.....#.##...#.##..##########.
11# #.....##########....##...######
12# #.....#...##..#.##..####.######
13# .#...##....##.#.##..###..#####.
14# ..#.##......#.#.####...######..
15# ..#...........#.#############..
16# ..#...........#.#############..
17# ...##.........#.############...
18# ......#.......#.#########......
19# .......#......#.########.......
20# .........#####...#####.........
22# PROJECT: rotary_logger
24# CREATION DATE: 29-10-2025
25# LAST Modified: 10:46:20 27-03-2026
27# A module that provides a universal python light on iops way of logging to files your program execution.
29# COPYRIGHT: (c) Asperguide
30# PURPOSE: The file containing the code that is actually doing the heavy lifting, this is the file that takes a stream, logs it to a file while outputting it to the terminal.
32# +==== END rotary_logger =================+
36from pathlib
import Path
37from typing
import TextIO, Optional, Union, BinaryIO, List, Any
38from threading
import RLock
41 from .
import constants
as CONST
42 from .file_instance
import FileInstance
43 from .rogger
import Rogger, RI
45 import constants
as CONST
46 from file_instance
import FileInstance
47 from rogger
import Rogger, RI
51 """Mirror a TextIO stream to disk while preserving normal output.
53 This class is intentionally lightweight: it captures short-lived
54 references under a tiny lock and then performs I/O without holding
55 that lock. Terminal writes are performed on the caller thread and
56 are wrapped to avoid raising unexpected errors back into the
57 application; disk writes are delegated to a `FileInstance` which
58 buffers and handles rotation.
63 root: Union[str, Path, FileInstance],
64 original_stream: TextIO,
66 max_size_mb: Optional[int] =
None,
67 flush_size: Optional[int] =
None,
68 mode: CONST.StdMode = CONST.StdMode.STDUNKNOWN,
69 error_mode: CONST.ErrorMode = CONST.ErrorMode.WARN_NO_PIPE,
70 encoding: Optional[str] =
None,
71 log_to_file: bool =
True,
72 log_function_calls: bool =
False
74 """Initialise a new TeeStream.
76 Mirrors original_stream to disk via a FileInstance while forwarding
77 every write transparently to the caller.
80 root (Union[str, Path, FileInstance]): Path, string, or FileInstance describing the log destination.
81 original_stream (TextIO): The TextIO stream to mirror (usually sys.stdout).
84 max_size_mb (Optional[int]): Optional maximum log-file size in MB; forwarded to FileInstance. Default: None
85 flush_size (Optional[int]): Optional buffer-flush threshold in bytes; forwarded to FileInstance. Default: None
86 mode (CONST.StdMode): Which standard stream this instance wraps. Default: CONST.StdMode.STDUNKNOWN
87 error_mode (CONST.ErrorMode): Broken-pipe handling policy. Default: CONST.ErrorMode.WARN_NO_PIPE
88 encoding (Optional[str]): Optional file-encoding override; forwarded to FileInstance. Default: None
89 log_to_file (bool): Whether disk logging is enabled on construction. Default: True
92 ValueError: If root is not a str, Path, or FileInstance.
96 if isinstance(root, (Path, str)):
98 elif isinstance(root, FileInstance):
102 f
"{CONST.MODULE_NAME} Unsupported type for the file {type(root)}"
115 f
"{CONST.MODULE_NAME} No stream available"
122 f
"TeeStream initialized (mode={self.stream_mode}, log_to_file={log_to_file})",
123 stream=CONST.RAW_STDOUT
125 except (AttributeError, OSError, ValueError):
130 """Best-effort cleanup on object deletion.
132 Attempts to flush buffered data but never raises. Note that __del__
133 may not be called at interpreter shutdown; callers that need
134 deterministic flushing should invoke flush() explicitly before
135 releasing the object. OSError and ValueError are intentionally
136 swallowed so that interpreter-shutdown teardown cannot trigger
137 unexpected tracebacks.
141 except (OSError, ValueError):
147 def _get_correct_prefix(self, function_call: CONST.PrefixFunctionCall = CONST.PrefixFunctionCall.EMPTY) -> str:
148 """Return the correct prefix string for the configured StdMode.
150 The returned string already contains a trailing space when non-empty so
151 that callers can concatenate it with the message directly. Returns an
152 empty string when logging to file is disabled, when no FileInstance is
153 set, or when the Prefix configuration has no flags enabled.
156 The prefix string (with trailing space) matching the active StdMode,
160 _file_inst: Optional[FileInstance] =
None
161 _prefix: Optional[CONST.Prefix] =
None
162 _mode: Optional[CONST.StdMode] =
None
165 file_lock = object.__getattribute__(self,
'_file_lock')
166 file_inst = object.__getattribute__(self,
'file_instance')
167 stream_mode = object.__getattribute__(self,
'stream_mode')
168 except AttributeError:
174 _file_inst = file_inst
175 if not isinstance(stream_mode, CONST.StdMode):
179 if _file_inst
is None:
184 if not _file_inst.get_log_to_file():
186 except (OSError, ValueError, AttributeError):
191 _prefix = _file_inst.get_prefix()
192 except (OSError, ValueError, AttributeError):
196 _final_prefix: str =
""
199 elif _prefix.std_err
and _mode == CONST.StdMode.STDERR:
200 _final_prefix = CONST.CORRECT_PREFIX[CONST.StdMode.STDERR]
201 elif _prefix.std_in
and _mode == CONST.StdMode.STDIN:
202 _final_prefix = CONST.CORRECT_PREFIX[CONST.StdMode.STDIN]
203 elif _prefix.std_out
and _mode == CONST.StdMode.STDOUT:
204 _final_prefix = CONST.CORRECT_PREFIX[CONST.StdMode.STDOUT]
205 elif _prefix.std_in
or _prefix.std_err
or _prefix.std_out:
206 _final_prefix = CONST.CORRECT_PREFIX[CONST.StdMode.STDUNKNOWN]
210 _final_prefix = f
"{_final_prefix}{function_call.value}"
211 if _final_prefix !=
"":
212 _final_prefix = f
"{_final_prefix}{CONST.SPACE}"
215 def _write_to_log(self, data: Union[str, List[str]], function_call: CONST.PrefixFunctionCall) ->
None:
216 """Write data to the log file if file logging is enabled.
218 Shared helper used by write(), writelines(), read(), readline() and
219 readlines() to avoid duplicating the snapshot-check-prefix-write
220 sequence in every method.
223 data (str): The string to append to the log file.
224 function_call (CONST.PrefixFunctionCall): Context passed to
225 _get_correct_prefix() to select the right prefix.
227 _file_instance: Optional[FileInstance] =
None
230 file_lock = object.__getattribute__(self,
'_file_lock')
231 file_inst = object.__getattribute__(self,
'file_instance')
232 except AttributeError:
237 _file_instance = file_inst
238 if not _file_instance:
241 if not _file_instance.get_log_to_file():
243 except (OSError, ValueError, AttributeError):
247 function_call=function_call)
248 except (OSError, ValueError, AttributeError):
251 if isinstance(data, list):
253 _file_instance.write(f
"{_prefix}{i}")
255 _file_instance.write(f
"{_prefix}{data}")
256 except (OSError, ValueError):
258 err_msg = f
"{CONST.MODULE_NAME} Error writing to log file"
259 self.
rogger.log_error(err_msg, stream=CONST.RAW_STDERR)
260 sys.stderr.write(f
"{err_msg}\n")
265 """Return the underlying stream, raising if it has been cleared.
267 Used as a guard by every delegating method so that operations on an
268 uninitialised or already-destroyed TeeStream fail predictably rather
269 than with an obscure AttributeError.
272 AttributeError: If original_stream is None.
275 The original TextIO stream passed at construction time.
279 stream = object.__getattribute__(self,
'original_stream')
280 if stream
is not None:
282 raise object.__getattribute__(self,
'stream_not_present')
284 def write(self, message: str) ->
None:
285 """Write message to the original stream and buffer it to the log file.
287 Thread-safe: the FileInstance reference is captured under a lock before
288 any I/O is performed. The terminal write is carried out on the caller
289 thread with explicit BrokenPipeError / OSError handling; disk writes
290 are delegated to FileInstance.write().
293 message (str): The string to write.
295 _tmp_message: str = message
300 except BrokenPipeError:
301 if self.
error_mode in (CONST.ErrorMode.EXIT, CONST.ErrorMode.EXIT_NO_PIPE):
302 sys.exit(CONST.ERROR)
303 elif self.
error_mode in (CONST.ErrorMode.WARN, CONST.ErrorMode.WARN_NO_PIPE):
306 CONST.BROKEN_PIPE_ERROR,
307 stream=CONST.RAW_STDERR
309 sys.stderr.write(f
"{CONST.BROKEN_PIPE_ERROR}\n")
312 except OSError
as exc:
315 err_msg = f
"{CONST.MODULE_NAME} I/O error writing to original stream: {exc}"
316 self.
rogger.log_error(err_msg, stream=CONST.RAW_STDERR)
317 sys.stderr.write(f
"{err_msg}\n")
322 self.
_write_to_log(_tmp_message, CONST.PrefixFunctionCall.WRITE)
326 f
"write: forwarded {len(_tmp_message)} chars to original stream (mode={self.stream_mode})",
327 stream=CONST.RAW_STDOUT
329 except (AttributeError, OSError, ValueError):
333 """Write a list of strings to the original stream and buffer them to the log file.
335 Thread-safe: behaves like write() but accepts a sequence of strings,
336 forwarding the entire sequence to the original stream via writelines()
337 and then writing each element individually to FileInstance with the
341 lines (List[str]): The sequence of strings to write.
343 _tmp_message: List[str] = lines.copy()
347 except BrokenPipeError:
348 if self.
error_mode in (CONST.ErrorMode.EXIT, CONST.ErrorMode.EXIT_NO_PIPE):
349 sys.exit(CONST.ERROR)
350 elif self.
error_mode in (CONST.ErrorMode.WARN, CONST.ErrorMode.WARN_NO_PIPE):
353 CONST.BROKEN_PIPE_ERROR,
354 stream=CONST.RAW_STDERR
356 sys.stderr.write(f
"{CONST.BROKEN_PIPE_ERROR}\n")
359 except OSError
as exc:
362 err_msg = f
"{CONST.MODULE_NAME} I/O error writing to original stream: {exc}"
363 self.
rogger.log_error(err_msg, stream=CONST.RAW_STDERR)
364 sys.stderr.writelines(f
"{err_msg}\n")
368 self.
_write_to_log(_tmp_message, CONST.PrefixFunctionCall.WRITELINES)
371 for l
in _tmp_message:
374 f
"writelines: forwarded {total} chars across {len(_tmp_message)} items (mode={self.stream_mode})",
375 stream=CONST.RAW_STDOUT
377 except (AttributeError, OSError, ValueError):
380 def read(self, size: int = -1) -> str:
381 """Read and return up to size characters from the original stream.
384 size (int): Maximum number of characters to read; -1 reads until EOF. Default: -1
387 AttributeError: If the original stream is not set.
398 f
"read: read {len(data)} chars from original stream (mode={self.stream_mode})",
399 stream=CONST.RAW_STDOUT
401 except (AttributeError, OSError, ValueError):
406 """Read and return one line from the original stream.
409 size (int): If non-negative, at most size characters are read; -1 reads until a newline or EOF. Default: -1
412 AttributeError: If the original stream is not set.
415 The line read, including the trailing newline if present.
421 f
"readline: read {len(data)} chars from original stream (mode={self.stream_mode})",
422 stream=CONST.RAW_STDOUT
424 except (AttributeError, OSError, ValueError):
429 """Read and return a list of lines from the original stream.
432 hint (int): If non-negative, approximately hint bytes or characters are read; -1 reads until EOF. Default: -1
435 AttributeError: If the original stream is not set.
438 List of lines read from the stream.
447 f
"readlines: read {len(data)} lines, {total} chars (mode={self.stream_mode})",
448 stream=CONST.RAW_STDOUT
450 except (AttributeError, OSError, ValueError):
455 """Best-effort flush of terminal and buffered file output.
457 Attempts to flush both the original stream and the associated
458 FileInstance. All OSError and ValueError exceptions are swallowed to
459 avoid crashing the caller. For stricter guarantees call
460 FileInstance.flush() directly. This method is also called by __del__
461 during object teardown.
465 original_stream =
None
467 original_stream = object.__getattribute__(self,
'original_stream')
468 rogger = object.__getattribute__(self,
'rogger')
469 stream_mode = object.__getattribute__(self,
'stream_mode')
470 except AttributeError:
476 f
"Flushing TeeStream (mode={stream_mode})",
477 stream=CONST.RAW_STDOUT
479 except (AttributeError, OSError, ValueError):
481 if original_stream
and not original_stream.closed:
483 original_stream.flush()
484 except OSError
as exc:
486 err_msg = f
"{CONST.MODULE_NAME} I/O error flushing original stream: {exc}"
487 rogger.log_error(err_msg, stream=CONST.RAW_STDERR)
488 sys.stderr.write(f
"{err_msg}")
492 _file_instance: Optional[FileInstance] =
None
495 file_lock = object.__getattribute__(self,
'_file_lock')
496 file_inst = object.__getattribute__(self,
'file_instance')
497 except AttributeError:
502 _file_instance = file_inst
506 if not _file_instance.get_log_to_file():
508 except (OSError, ValueError, AttributeError):
516 function_call=CONST.PrefixFunctionCall.FLUSH
518 except (OSError, ValueError, AttributeError):
520 _file_instance.write(_prefix)
521 _file_instance.flush()
524 f
"Flushed file_instance for mode={self.stream_mode}",
525 stream=CONST.RAW_STDOUT
527 except (AttributeError, OSError, ValueError):
529 except (OSError, ValueError):
532 f
"TeeStream flush encountered I/O error for mode={self.stream_mode}",
533 stream=CONST.RAW_STDERR
535 except (AttributeError, OSError, ValueError):
543 """Return the underlying binary buffer of the original stream.
546 AttributeError: If the original stream is not set.
549 The binary buffer exposed by the wrapped TextIO.
556 """Return whether the original stream is closed.
559 AttributeError: If the original stream is not set.
562 True if the wrapped stream has been closed, False otherwise.
565 self.
rogger.log_debug(f
"Closed: {data}", stream=CONST.RAW_STDOUT)
570 """Return the error-handling mode of the original stream.
573 AttributeError: If the original stream is not set.
576 The error-handling mode string (e.g. 'strict'), or None.
579 self.
rogger.log_debug(f
"Errors: {data}", stream=CONST.RAW_STDOUT)
584 """Return the character encoding of the original stream.
587 AttributeError: If the original stream is not set.
590 The encoding string (e.g. 'utf-8').
593 self.
rogger.log_debug(f
"Encoding: {data}", stream=CONST.RAW_STDOUT)
598 """Return whether line buffering is enabled on the original stream.
601 AttributeError: If the original stream is not set.
604 Non-zero if line buffering is active, zero otherwise.
608 f
"Line buffering: {data}",
609 stream=CONST.RAW_STDOUT
615 """Return the file-mode string of the original stream.
618 AttributeError: If the original stream is not set.
621 The mode string (e.g. 'w', 'r').
624 self.
rogger.log_debug(f
"Mode: {data}", stream=CONST.RAW_STDOUT)
628 def name(self) -> Union[str, Any]:
629 """Return the name of the original stream.
632 AttributeError: If the original stream is not set.
635 The stream name; typically a file path string or an integer file
636 descriptor for standard streams.
639 self.
rogger.log_debug(f
"Name: {data}", stream=CONST.RAW_STDOUT)
644 """Return the newline translation used by the original stream.
647 AttributeError: If the original stream is not set.
650 A string, tuple of strings, or None as described by the standard
651 io.TextIOBase.newlines attribute.
654 self.
rogger.log_debug(f
"Newlines: {data}", stream=CONST.RAW_STDOUT)
658 """Flush pending data and close the original stream.
660 Calls flush() before delegating to the original stream's close()
661 method, ensuring buffered log data is written before the stream is
665 AttributeError: If the original stream is not set.
669 f
"Closing TeeStream (mode={self.stream_mode})",
670 stream=CONST.RAW_STDOUT
672 except (AttributeError, OSError, ValueError):
678 """Return the underlying file descriptor of the original stream.
681 AttributeError: If the original stream is not set.
682 io.UnsupportedOperation: If the stream has no file descriptor.
685 Integer file descriptor.
688 self.
rogger.log_debug(f
"Fileno: {data}", stream=CONST.RAW_STDOUT)
692 """Return whether the original stream is connected to a TTY device.
695 AttributeError: If the original stream is not set.
698 True if the stream is interactive (TTY), False otherwise.
701 self.
rogger.log_debug(f
"IsATTY: {data}", stream=CONST.RAW_STDOUT)
705 """Return whether the original stream supports reading.
708 AttributeError: If the original stream is not set.
711 True if the stream can be read from, False otherwise.
714 self.
rogger.log_debug(f
"Readable: {data}", stream=CONST.RAW_STDOUT)
717 def seek(self, offset: int, whence: int = 0) -> int:
718 """Move the stream position to the given byte offset.
721 offset (int): Number of bytes to move the position.
724 whence (int): Reference point: 0 = start, 1 = current, 2 = end. Default: 0
727 AttributeError: If the original stream is not set.
730 The new absolute stream position.
734 f
"Seek(offset={offset},whence={whence}): {data}",
735 stream=CONST.RAW_STDOUT
740 """Return whether the original stream supports random-access seeking.
743 AttributeError: If the original stream is not set.
746 True if the stream supports seek() and tell(), False otherwise.
749 self.
rogger.log_debug(f
"Seekable: {data}", stream=CONST.RAW_STDOUT)
753 """Return the current stream position of the original stream.
756 AttributeError: If the original stream is not set.
759 The current byte offset within the stream.
762 self.
rogger.log_debug(f
"Tell: {data}", stream=CONST.RAW_STDOUT)
765 def truncate(self, size: Optional[int] =
None) -> int:
766 """Truncate the original stream to at most size bytes.
769 size (Optional[int]): Desired size in bytes; defaults to the current stream position. Default: None
772 AttributeError: If the original stream is not set.
775 The new file size in bytes.
779 f
"Truncate(size={size}): {data}",
780 stream=CONST.RAW_STDOUT
785 """Return whether the original stream supports writing.
788 AttributeError: If the original stream is not set.
791 True if the stream can be written to, False otherwise.
794 self.
rogger.log_debug(f
"Writable: {data}", stream=CONST.RAW_STDOUT)
798 """Enter the runtime context for the original stream.
800 Delegates to the wrapped stream's __enter__() so that TeeStream can be
801 used as a context manager wherever a plain TextIO is expected.
804 AttributeError: If the original stream is not set.
807 The original stream as returned by its own __enter__().
812 def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]:
813 """Exit the runtime context and delegate to the original stream.
815 Delegates to the wrapped stream's __exit__() so that TeeStream can be
816 used as a context manager wherever a plain TextIO is expected.
819 exc_type (type): The exception type, or None if no exception.
820 exc_val (BaseException): The exception instance, or None.
821 exc_tb (traceback): The traceback, or None.
824 AttributeError: If the original stream is not set.
827 The return value of the original stream's __exit__(); True suppresses
828 the exception, False or None propagates it.
832 f
"__exit__(exc_type={exc_type},exc_val={exc_val},exc_tb={exc_tb}): {data}",
833 stream=CONST.RAW_STDOUT
838 """Return an iterator over the stream (line-by-line).
840 This makes `for line in tee_stream:` work like the underlying
841 TextIO object. Iteration yields lines as produced by `readline()`.
846 """Return the next line from the wrapped stream, logging it.
848 Prefer delegating to the underlying stream's `__next__` if it
849 exists (and then log the returned line). Otherwise fall back to
850 `readline()` which already takes care of logging. Raise
851 `StopIteration` when the stream is exhausted.
854 next_method = getattr(stream,
"__next__",
None)
855 if callable(next_method):
861 CONST.PrefixFunctionCall.READLINE
863 except (OSError, ValueError, AttributeError):
874 """Detach and return the underlying binary buffer if supported.
876 Delegates to the wrapped stream's `detach()` when present. If the
877 wrapped stream does not support `detach()` an AttributeError is
878 raised to mirror standard behavior.
881 detach_method = getattr(stream,
"detach",
None)
882 if callable(detach_method):
883 return detach_method()
884 raise AttributeError(
885 f
"{CONST.MODULE_NAME} underlying stream has no detach() method")
888 """Delegate attribute access to the wrapped stream for missing attrs.
890 This helps the TeeStream more closely mimic the wrapped TextIO
891 object without having to manually proxy every attribute.
896 return getattr(stream, name)
CONST.StdMode stream_mode
str _get_correct_prefix(self, CONST.PrefixFunctionCall function_call=CONST.PrefixFunctionCall.EMPTY)
str read(self, int size=-1)
CONST.ErrorMode error_mode
None write(self, str message)
None _write_to_log(self, Union[str, List[str]] data, CONST.PrefixFunctionCall function_call)
__getattr__(self, str name)
Union[str, Any] name(self)
int truncate(self, Optional[int] size=None)
list[str] readlines(self, int hint=-1)
TextIO _get_stream_if_present(self)
Optional[str] errors(self)
__init__(self, Union[str, Path, FileInstance] root, TextIO original_stream, *, Optional[int] max_size_mb=None, Optional[int] flush_size=None, CONST.StdMode mode=CONST.StdMode.STDUNKNOWN, CONST.ErrorMode error_mode=CONST.ErrorMode.WARN_NO_PIPE, Optional[str] encoding=None, bool log_to_file=True, bool log_function_calls=False)
str readline(self, int size=-1)
AttributeError stream_not_present
None writelines(self, List[str] lines)
Optional[bool] __exit__(self, exc_type, exc_val, exc_tb)
int seek(self, int offset, int whence=0)