11import logging
2+ import os
23import re
3- from typing import Dict
4+ import sys
5+ import time
6+ from typing import Dict , List , Optional , Type , TypeVar
47
58from rich ._log_render import LogRender
69from rich .logging import RichHandler
710from rich .text import Text
811
12+ from revup .version import REVUP_VERSION
13+
14+ HandlerType = TypeVar ("HandlerType" , bound = logging .Handler )
15+
916
1017class RedactingFilter (logging .Filter ):
1118 redactions : Dict [str , str ]
@@ -53,21 +60,47 @@ def set_render(self, log_render: LogRender) -> None:
5360 self ._log_render = log_render
5461
5562
56- def configure_logger (debug : bool , redactions : Dict [str , str ]) -> None :
57- root_logger = logging .getLogger ()
58- root_logger .setLevel (logging .DEBUG if debug else logging .INFO )
59-
60- for h in root_logger .handlers :
61- if isinstance (h , RichHandler ):
62- for k , v in redactions .items ():
63- for f in h .filters :
64- if isinstance (f , RedactingFilter ):
65- f .redact (k , v )
66- return
67-
68- log_filter = RedactingFilter ()
69- for k , v in redactions .items ():
70- log_filter .redact (k , v )
63+ def find_handler (handler_type : Type [HandlerType ]) -> Optional [HandlerType ]:
64+ """
65+ Find an already installed handler of the given type.
66+ """
67+ for h in logging .getLogger ().handlers :
68+ if isinstance (h , handler_type ):
69+ return h
70+ return None
71+
72+
73+ def get_log_filter () -> RedactingFilter :
74+ """
75+ The filter shared by all handlers, so redactions added at any point apply to all
76+ backends. Must be called after configure_logger().
77+ """
78+ for h in logging .getLogger ().handlers :
79+ for f in h .filters :
80+ if isinstance (f , RedactingFilter ):
81+ return f
82+ raise AssertionError ("Logger must be configured before redacting" )
83+
84+
85+ def prune_old_logs (log_dir : str , keep : int ) -> None :
86+ """
87+ Delete all but the newest `keep` log files. Names sort chronologically so we can
88+ prune without stat-ing every file.
89+ """
90+ try :
91+ names : List [str ] = sorted (n for n in os .listdir (log_dir ) if n .endswith (".log" ))
92+ except OSError :
93+ return
94+
95+ for name in names [: max (len (names ) - keep , 0 )]:
96+ try :
97+ os .remove (os .path .join (log_dir , name ))
98+ except OSError :
99+ # Another revup may have pruned it already, or we may not own it.
100+ pass
101+
102+
103+ def make_console_handler (log_filter : logging .Filter ) -> RichHandler :
71104 handler = RevupRichHandler (keywords = [])
72105 handler .addFilter (log_filter )
73106 handler .set_render (
@@ -79,12 +112,74 @@ def configure_logger(debug: bool, redactions: Dict[str, str]) -> None:
79112 )
80113 )
81114 handler .highlighter = None # type: ignore
115+ return handler
116+
117+
118+ def make_file_handler (log_filter : logging .Filter ) -> Optional [logging .FileHandler ]:
119+ """
120+ Create a handler that writes debug logs for this invocation to a new file. Returns
121+ None if the file couldn't be created, since logging is never worth failing over.
122+ """
123+ # Follow the XDG base directory spec for state that should persist between restarts
124+ # but isn't precious.
125+ state_home = os .environ .get ("XDG_STATE_HOME" )
126+ if not state_home :
127+ state_home = os .path .join (os .path .expanduser ("~" ), ".local" , "state" )
128+ log_dir = os .path .join (state_home , "revup" , "logs" )
129+
130+ try :
131+ os .makedirs (log_dir , exist_ok = True )
132+ # Keep 19 old logs plus the one we're about to add, for 20 runs of history.
133+ prune_old_logs (log_dir , 19 )
134+ path = os .path .join (
135+ log_dir , "{}-{}.log" .format (time .strftime ("%Y%m%dT%H%M%S" ), os .getpid ())
136+ )
137+ handler = logging .FileHandler (path , encoding = "utf-8" )
138+ except OSError as e :
139+ logging .warning (f"Couldn't write verbose logs to { log_dir } : { e } " )
140+ return None
141+
142+ handler .setLevel (logging .DEBUG )
143+ handler .setFormatter (logging .Formatter ("%(asctime)s %(levelname)-7s %(message)s" ))
144+ handler .addFilter (log_filter )
145+ return handler
146+
147+
148+ def configure_logger (debug : bool , redactions : Dict [str , str ], log_to_file : bool = False ) -> None :
149+ """
150+ Set up logging backends. Can be called more than once to update options, which is
151+ needed since some logging happens before config and args are fully parsed.
152+
153+ Args:
154+ debug: Show debug logs on the console.
155+ redactions: Strings that must never appear in any log, and their replacements.
156+ log_to_file: Additionally write debug logs to a file in the state directory,
157+ regardless of what the console shows.
158+ """
159+ root_logger = logging .getLogger ()
160+ # The root level must allow debug logs through if any handler wants them; each
161+ # handler then decides for itself what to show.
162+ root_logger .setLevel (logging .DEBUG if debug or log_to_file else logging .INFO )
163+
164+ console_handler = find_handler (RichHandler )
165+ if console_handler is None :
166+ console_handler = make_console_handler (RedactingFilter ())
167+ root_logger .addHandler (console_handler )
168+ console_handler .setLevel (logging .DEBUG if debug else logging .INFO )
169+
170+ log_filter = get_log_filter ()
171+ for k , v in redactions .items ():
172+ log_filter .redact (k , v )
82173
83- root_logger .addHandler (handler )
174+ if log_to_file and find_handler (logging .FileHandler ) is None :
175+ file_handler = make_file_handler (log_filter )
176+ if file_handler is not None :
177+ root_logger .addHandler (file_handler )
178+ # Record what was run, since the file outlives the terminal it came from.
179+ logging .debug ("revup {} : {}" .format (REVUP_VERSION , " " .join (sys .argv [1 :])))
84180
85181
86182def redact (redactions : Dict [str , str ]) -> None :
87- log_filter = logging .getLogger ().handlers [0 ].filters [0 ]
88- assert isinstance (log_filter , RedactingFilter )
183+ log_filter = get_log_filter ()
89184 for k , v in redactions .items ():
90185 log_filter .redact (k , v )
0 commit comments