11"""Set up logging for the documentation build process."""
22
33import atexit
4+ from contextlib import suppress
5+ import contextvars
46import copy
57import importlib
68import logging
79import logging .handlers
810import os
11+ from pathlib import Path
912import queue
1013import threading
1114import time
12- import contextvars
13- from pathlib import Path
14- from typing import Any , List
15+ from typing import Any , Self
1516
1617from .constants import APP_NAME , BASE_LOG_DIR , GITLOGGER_NAME
1718
2021_original_emit = logging .StreamHandler .emit
2122
2223
23- def _safe_emit (self , record ):
24- try :
24+ def _safe_emit (self : Self , record : logging .LogRecord ) -> None : # pyright: ignore[reportGeneralTypeIssues]
25+ # Happens if a background thread logs after sys.stdout/stderr closed.
26+ with suppress (ValueError ):
2527 _original_emit (self , record )
26- except ValueError :
27- # Happens if a background thread logs after sys.stdout/stderr closed.
28- pass
2928
3029
3130logging .StreamHandler .emit = _safe_emit
@@ -80,46 +79,50 @@ def _safe_emit(self, record):
8079# --- Context-aware global state ---
8180_LOGGING_STATE : dict [str , contextvars .ContextVar ] = {
8281 "listener" : contextvars .ContextVar ("listener" , default = None ),
83- "handlers" : contextvars .ContextVar ("handlers" , default = []),
84- "background_threads" : contextvars .ContextVar ("background_threads" , default = []),
82+ # Use None as the default to avoid sharing mutable defaults across
83+ # contexts. Callers should coerce a None to a fresh list before use.
84+ "handlers" : contextvars .ContextVar ("handlers" , default = None ),
85+ "background_threads" : contextvars .ContextVar ("background_threads" , default = None ),
8586}
8687
8788
88- def _shutdown_logging ():
89+ def _shutdown_logging () -> None :
8990 """Ensure all logging threads and handlers shut down cleanly."""
9091 listener = _LOGGING_STATE ["listener" ].get ()
91- handlers : List [logging .Handler ] = _LOGGING_STATE ["handlers" ].get ()
92- bg_threads : List [threading .Thread ] = _LOGGING_STATE ["background_threads" ].get ()
92+ handlers : list [logging .Handler ] = _LOGGING_STATE ["handlers" ].get ()
93+ bg_threads : list [threading .Thread ] | None = _LOGGING_STATE ["background_threads" ].get ()
94+
95+ # Defensive: some tests or earlier code may have mutated the stored
96+ # value accidentally (e.g. to the `list` type). Coerce to an iterable
97+ # list to avoid TypeErrors during shutdown.
98+ if not isinstance (bg_threads , list ):
99+ bg_threads = []
93100
94101 if listener :
95- try :
102+ with suppress ( Exception ) :
96103 listener .stop ()
97- except Exception :
98- pass
99104
100105 for handler in handlers :
101- try :
106+ with suppress ( Exception ) :
102107 handler .close ()
103- except Exception :
104- pass
105108
106109 # Join all registered background threads
107110 for t in bg_threads :
108111 if t .is_alive ():
109- try :
112+ with suppress ( Exception ) :
110113 t .join (timeout = 5 )
111- except Exception :
112- pass
113114
114115 # Reset contextvars
115116 _LOGGING_STATE ["listener" ].set (None )
116117 _LOGGING_STATE ["handlers" ].set ([])
117118 _LOGGING_STATE ["background_threads" ].set ([])
118119
119120
120- def register_background_thread (thread : threading .Thread ):
121+ def register_background_thread (thread : threading .Thread ) -> None :
121122 """Register a thread to be joined on logging shutdown."""
122123 threads = _LOGGING_STATE ["background_threads" ].get ()
124+ if not isinstance (threads , list ):
125+ threads = []
123126 threads .append (thread )
124127 _LOGGING_STATE ["background_threads" ].set (threads )
125128
@@ -131,15 +134,56 @@ def create_base_log_dir(base_log_dir: str | Path = BASE_LOG_DIR) -> Path:
131134 return log_dir
132135
133136
134- def _resolve_class (path : str ):
137+ def _resolve_class (path : str ) -> type :
135138 """Dynamically imports and returns a class from a string path."""
136139 module_name , class_name = path .rsplit ("." , 1 )
137140 module = importlib .import_module (module_name )
138141 return getattr (module , class_name )
139142
140143
144+ def build_handlers_from_config (config : dict [str , Any ]) -> list [logging .Handler ]:
145+ """Build handler instances from a logging config dict without starting listeners.
146+
147+ This is a small helper useful for unit tests: it constructs handler
148+ objects and attaches formatters according to the provided `config`
149+ but does not start any background listener or register global state.
150+ """
151+ built_handlers : list [logging .Handler ] = []
152+
153+ handler_keys = ("class" , "formatter" , "level" , "class_name" )
154+ formatter_keys = ("class" , "formatter" , "level" , "class_name" , "validate" )
155+
156+ for _ , hconf in config .get ("handlers" , {}).items ():
157+ cls = _resolve_class (hconf ["class" ])
158+ handler_args = {k : v for k , v in hconf .items () if k not in handler_keys }
159+ handler = cls (** handler_args )
160+ handler .setLevel (hconf .get ("level" , "NOTSET" ))
161+
162+ formatter_name = hconf .get ("formatter" )
163+ if formatter_name and formatter_name in config .get ("formatters" , {}):
164+ fmt_conf = config ["formatters" ][formatter_name ]
165+ formatter_kwargs = {
166+ k : v
167+ for k , v in fmt_conf .items ()
168+ if k not in formatter_keys and k not in ["format" ]
169+ }
170+ formatter_kwargs ["fmt" ] = fmt_conf .get ("format" )
171+ formatter_kwargs ["datefmt" ] = fmt_conf .get ("datefmt" )
172+ formatter_kwargs ["style" ] = fmt_conf .get ("style" )
173+ formatter_kwargs = {
174+ k : v for k , v in formatter_kwargs .items ()
175+ if v is not None
176+ }
177+ fmt_cls = _resolve_class (fmt_conf .get ("class" , "logging.Formatter" ))
178+ handler .setFormatter (fmt_cls (** formatter_kwargs ))
179+
180+ built_handlers .append (handler )
181+
182+ return built_handlers
183+
184+
141185def setup_logging (cliverbosity : int , user_config : dict [str , Any ] | None = None ) -> None :
142- """Sets up a non-blocking, configurable logging system."""
186+ """Set up a non-blocking, configurable logging system."""
143187 config = copy .deepcopy (DEFAULT_LOGGING_CONFIG )
144188
145189 if user_config and "logging" in user_config :
@@ -162,38 +206,14 @@ def deep_merge(target: dict, source: dict) -> None:
162206 log_path = log_dir / log_filename
163207 config ["handlers" ]["file" ]["filename" ] = str (log_path )
164208
165- built_handlers = []
166-
167- # --- Handler Initialization ---
168- HANDLER_INTERNAL_KEYS = ["class" , "formatter" , "level" , "class_name" ]
169- FORMATTER_INTERNAL_KEYS = ["class" , "formatter" , "level" , "class_name" , "validate" ]
170-
171- for hname , hconf in config ["handlers" ].items ():
172- cls = _resolve_class (hconf ["class" ])
173- handler_args = {k : v for k , v in hconf .items () if k not in HANDLER_INTERNAL_KEYS }
174- handler = cls (** handler_args )
175- handler .setLevel (hconf .get ("level" , "NOTSET" ))
176-
177- formatter_name = hconf .get ("formatter" )
178- if formatter_name and formatter_name in config ["formatters" ]:
179- fmt_conf = config ["formatters" ][formatter_name ]
180- formatter_kwargs = {
181- k : v for k , v in fmt_conf .items () if k not in FORMATTER_INTERNAL_KEYS and k not in ["format" ]
182- }
183- formatter_kwargs ["fmt" ] = fmt_conf .get ("format" )
184- formatter_kwargs ["datefmt" ] = fmt_conf .get ("datefmt" )
185- formatter_kwargs ["style" ] = fmt_conf .get ("style" )
186- formatter_kwargs = {k : v for k , v in formatter_kwargs .items () if v is not None }
187- fmt_cls = _resolve_class (fmt_conf .get ("class" , "logging.Formatter" ))
188- handler .setFormatter (fmt_cls (** formatter_kwargs ))
189-
190- built_handlers .append (handler )
209+ # Build handlers (use helper to keep logic in one place)
210+ handlers = build_handlers_from_config (config )
191211
192212 # --- Asynchronous Queue Setup ---
193213 log_queue = queue .Queue (- 1 )
194214 queue_handler = logging .handlers .QueueHandler (log_queue )
195215 listener = logging .handlers .QueueListener (
196- log_queue , * built_handlers , respect_handler_level = True
216+ log_queue , * handlers , respect_handler_level = True
197217 )
198218 listener .start ()
199219
@@ -210,5 +230,5 @@ def deep_merge(target: dict, source: dict) -> None:
210230
211231 # --- Register graceful shutdown ---
212232 _LOGGING_STATE ["listener" ].set (listener )
213- _LOGGING_STATE ["handlers" ].set (built_handlers )
233+ _LOGGING_STATE ["handlers" ].set (handlers )
214234 atexit .register (_shutdown_logging )
0 commit comments