77import logging .handlers
88import os
99import queue
10+ import threading
1011import time
12+ import contextvars
1113from pathlib import Path
12- from typing import Any , Sequence
14+ from typing import Any , List
1315
1416from .constants import APP_NAME , BASE_LOG_DIR , GITLOGGER_NAME
1517
18+ # --- Defensive macOS-safe patch ---
19+ logging .raiseExceptions = False # Suppress internal logging exception tracebacks
20+ _original_emit = logging .StreamHandler .emit
21+
22+
23+ def _safe_emit (self , record ):
24+ try :
25+ _original_emit (self , record )
26+ except ValueError :
27+ # Happens if a background thread logs after sys.stdout/stderr closed.
28+ pass
29+
30+
31+ logging .StreamHandler .emit = _safe_emit
32+
1633# --- Default Logging Configuration ---
17- # This dictionary provides the minimal required configuration structure.
1834DEFAULT_LOGGING_CONFIG = {
1935 "version" : 1 ,
2036 "disable_existing_loggers" : False ,
5975 },
6076}
6177
62- LOGLEVELS = {
63- 0 : logging .WARNING ,
64- 1 : logging .INFO ,
65- 2 : logging .DEBUG ,
78+ LOGLEVELS = {0 : logging .WARNING , 1 : logging .INFO , 2 : logging .DEBUG }
79+
80+ # --- Context-aware global state ---
81+ _LOGGING_STATE : dict [str , contextvars .ContextVar ] = {
82+ "listener" : contextvars .ContextVar ("listener" , default = None ),
83+ "handlers" : contextvars .ContextVar ("handlers" , default = []),
84+ "background_threads" : contextvars .ContextVar ("background_threads" , default = []),
6685}
6786
87+
88+ def _shutdown_logging ():
89+ """Ensure all logging threads and handlers shut down cleanly."""
90+ 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 ()
93+
94+ if listener :
95+ try :
96+ listener .stop ()
97+ except Exception :
98+ pass
99+
100+ for handler in handlers :
101+ try :
102+ handler .close ()
103+ except Exception :
104+ pass
105+
106+ # Join all registered background threads
107+ for t in bg_threads :
108+ if t .is_alive ():
109+ try :
110+ t .join (timeout = 5 )
111+ except Exception :
112+ pass
113+
114+ # Reset contextvars
115+ _LOGGING_STATE ["listener" ].set (None )
116+ _LOGGING_STATE ["handlers" ].set ([])
117+ _LOGGING_STATE ["background_threads" ].set ([])
118+
119+
120+ def register_background_thread (thread : threading .Thread ):
121+ """Register a thread to be joined on logging shutdown."""
122+ threads = _LOGGING_STATE ["background_threads" ].get ()
123+ threads .append (thread )
124+ _LOGGING_STATE ["background_threads" ].set (threads )
125+
126+
68127def create_base_log_dir (base_log_dir : str | Path = BASE_LOG_DIR ) -> Path :
69- """Create the base log directory if it doesn't exist.
70-
71- This directory is typically located at :file:`~/.local/state/docbuild/logs`
72- as per the XDG Base Directory Specification.
73- :param base_log_dir: The base directory where logs should be stored.
74- Considers the `XDG_STATE_HOME` environment variable if set.
75- :return: The path to the base log directory.
76- """
128+ """Create the base log directory if it doesn't exist."""
77129 log_dir = Path (os .getenv ("XDG_STATE_HOME" , base_log_dir ))
78130 log_dir .mkdir (mode = 0o700 , parents = True , exist_ok = True )
79131 return log_dir
80132
133+
81134def _resolve_class (path : str ):
82135 """Dynamically imports and returns a class from a string path."""
83136 module_name , class_name = path .rsplit ("." , 1 )
84137 module = importlib .import_module (module_name )
85138 return getattr (module , class_name )
86139
87- def setup_logging (
88- cliverbosity : int ,
89- user_config : dict [str , Any ] | None = None ,
90- ) -> None :
91- """Sets up a non-blocking, configurable logging system.
92-
93- This function merges the default logging configuration with the validated
94- user configuration and sets up the asynchronous handlers.
95- """
140+
141+ def setup_logging (cliverbosity : int , user_config : dict [str , Any ] | None = None ) -> None :
142+ """Sets up a non-blocking, configurable logging system."""
96143 config = copy .deepcopy (DEFAULT_LOGGING_CONFIG )
97144
98145 if user_config and "logging" in user_config :
99- # Use a more robust deep merge approach
100146 def deep_merge (target : dict , source : dict ) -> None :
101147 for k , v in source .items ():
102148 if k in target and isinstance (target [k ], dict ) and isinstance (v , dict ):
103149 deep_merge (target [k ], v )
104150 else :
105151 target [k ] = v
106-
152+
107153 deep_merge (config , user_config .get ("logging" , {}))
108154
109155 # --- Verbosity & Log File Path Setup ---
@@ -117,69 +163,52 @@ def deep_merge(target: dict, source: dict) -> None:
117163 config ["handlers" ]["file" ]["filename" ] = str (log_path )
118164
119165 built_handlers = []
120-
121- # --- Handler and Listener Initialization ---
122- built_handlers = []
123-
124- # Required keys to ignore when instantiating a handler (they are for dictConfig lookup)
166+
167+ # --- Handler Initialization ---
125168 HANDLER_INTERNAL_KEYS = ["class" , "formatter" , "level" , "class_name" ]
126- FORMATTER_INTERNAL_KEYS = ["class" , "formatter" , "level" , "class_name" , "validate" ] # Added 'validate'
169+ FORMATTER_INTERNAL_KEYS = ["class" , "formatter" , "level" , "class_name" , "validate" ]
127170
128171 for hname , hconf in config ["handlers" ].items ():
129172 cls = _resolve_class (hconf ["class" ])
130-
131- handler_args = {
132- k : v for k , v in hconf .items () if k not in HANDLER_INTERNAL_KEYS
133- }
134-
173+ handler_args = {k : v for k , v in hconf .items () if k not in HANDLER_INTERNAL_KEYS }
135174 handler = cls (** handler_args )
136-
137175 handler .setLevel (hconf .get ("level" , "NOTSET" ))
138-
176+
139177 formatter_name = hconf .get ("formatter" )
140178 if formatter_name and formatter_name in config ["formatters" ]:
141179 fmt_conf = config ["formatters" ][formatter_name ]
142-
143- # --- FIX APPLIED HERE ---
144- # Filter out keys that are Pydantic metadata/aliases (like 'class_name')
145- # We specifically filter out 'format' because the Formatter.__init__
146- # expects 'fmt' or uses 'style', not 'format' directly as a kwarg.
147180 formatter_kwargs = {
148- k : v for k , v in fmt_conf .items ()
149- if k not in FORMATTER_INTERNAL_KEYS and k not in ['format' ]
181+ k : v for k , v in fmt_conf .items () if k not in FORMATTER_INTERNAL_KEYS and k not in ["format" ]
150182 }
151-
152- # Pass 'fmt' instead of 'format' to the standard Formatter constructor
153- formatter_kwargs ['fmt' ] = fmt_conf .get ("format" )
154- formatter_kwargs ['datefmt' ] = fmt_conf .get ("datefmt" )
155- formatter_kwargs ['style' ] = fmt_conf .get ("style" ) # Should pass style if present
156-
157- # Remove keys that are None
183+ formatter_kwargs ["fmt" ] = fmt_conf .get ("format" )
184+ formatter_kwargs ["datefmt" ] = fmt_conf .get ("datefmt" )
185+ formatter_kwargs ["style" ] = fmt_conf .get ("style" )
158186 formatter_kwargs = {k : v for k , v in formatter_kwargs .items () if v is not None }
159-
160187 fmt_cls = _resolve_class (fmt_conf .get ("class" , "logging.Formatter" ))
161-
162188 handler .setFormatter (fmt_cls (** formatter_kwargs ))
163-
189+
164190 built_handlers .append (handler )
165-
191+
166192 # --- Asynchronous Queue Setup ---
167193 log_queue = queue .Queue (- 1 )
168194 queue_handler = logging .handlers .QueueHandler (log_queue )
169195 listener = logging .handlers .QueueListener (
170196 log_queue , * built_handlers , respect_handler_level = True
171197 )
172198 listener .start ()
173- atexit .register (listener .stop )
174199
175200 # --- Logger Initialization ---
176201 for lname , lconf in config ["loggers" ].items ():
177202 logger = logging .getLogger (lname )
178203 logger .setLevel (lconf ["level" ])
179204 logger .addHandler (queue_handler )
180205 logger .propagate = lconf .get ("propagate" , False )
181-
182- # Configure the root logger separately
206+
183207 root_logger = logging .getLogger ()
184208 root_logger .setLevel (config ["root" ]["level" ])
185- root_logger .addHandler (queue_handler )
209+ root_logger .addHandler (queue_handler )
210+
211+ # --- Register graceful shutdown ---
212+ _LOGGING_STATE ["listener" ].set (listener )
213+ _LOGGING_STATE ["handlers" ].set (built_handlers )
214+ atexit .register (_shutdown_logging )
0 commit comments