44The package could be installed as develop mode (`pip install -e .`) or normally (`pip install .`),
55so this module tries to support both cases.
66
7- This sets up the following variables:
7+ Public variables:
88- `default_log_level`: The default log level. (defaults to INFO)
99 Can be configured with the environment variable `{app_name_upper}_LOG_LEVEL`.
1010- `PROJECT_DIR`: The project directory, or None.
1717- `app_name`: The name of the module. (alias of `__name__`) (e.g., `ml_project`)
1818- `app_name_upper`: The name of the module in uppercase. (e.g., `ML_PROJECT`)
1919- `package_name`: The name of the package. (replaces `_` with `-`) (e.g., `ml-project`)
20- - `env_deferred_logger`: A logger that can be used before the logging system is configured.
21- It is later used in `ml_project.utils.log.setup_logging()`
2220
23- The following functions are exposed :
21+ Public functions:
2422- `update_data_dirs(data_dir)`: Update the data directories after the package is loaded.
2523
2624You can configure the app with the following environment variables:
2725- `{app_name_upper}_CONFIG_DIR`: The directory to search for the `.env` file.
2826- `{app_name_upper}_DATA_DIR`: The data directory.
2927- `{app_name_upper}_LOG_LEVEL`: The default log level.
28+
29+ Private variables:
30+ - `_env_deferred_logger`: A logger that can be used before the logging system is configured.
31+ It is later used in `setup_logging()`
3032"""
3133
3234# ruff: noqa: PLW0603
4648app_name_upper = app_name .upper ()
4749package_name = app_name .replace ("_" , "-" )
4850
51+ # ┌──────────────────────────────────────────────────────────────────────────────────┐
52+ # │ directory definitions and environment variables / dotenv │
53+ # └──────────────────────────────────────────────────────────────────────────────────┘
54+
4955# NOTE: The value is None if you haven't installed with `pip install -e .` (development mode).
5056# We make it None to discourage the use of this path. Only use for development.
5157PROJECT_DIR : Path | None = Path (__file__ ).parent .parent .parent
5258if PROJECT_DIR .name .startswith ("python3." ):
5359 PROJECT_DIR = None
5460
55- env_deferred_logger = DeferredLogger ()
61+ _env_deferred_logger = DeferredLogger ()
5662
5763
5864def load_dotenv_project_dir_or_config_dir (
@@ -69,7 +75,7 @@ def load_dotenv_project_dir_or_config_dir(
6975 rich .print (f":x: { config_dir_env } is set but { dotenv_file } does not exist." )
7076 sys .exit (1 )
7177 load_dotenv (dotenv_file , verbose = True )
72- env_deferred_logger .info (f"✅ Loaded environment variables from { dotenv_file } " )
78+ _env_deferred_logger .info (f"✅ Loaded environment variables from { dotenv_file } " )
7379 return dotenv_file
7480
7581 # if installed with `pip install -e .`
@@ -78,7 +84,7 @@ def load_dotenv_project_dir_or_config_dir(
7884 dotenv_file = PROJECT_DIR / ".env"
7985 if dotenv_file .exists ():
8086 load_dotenv (dotenv_file , verbose = True )
81- env_deferred_logger .info (
87+ _env_deferred_logger .info (
8288 f"✅ Loaded environment variables from { dotenv_file } "
8389 )
8490 return dotenv_file
@@ -90,10 +96,10 @@ def load_dotenv_project_dir_or_config_dir(
9096 dotenv_file = Path (config_dir ) / ".env"
9197 if dotenv_file .exists ():
9298 load_dotenv (dotenv_file , verbose = True )
93- env_deferred_logger .info (f"✅ Loaded environment variables from { dotenv_file } " )
99+ _env_deferred_logger .info (f"✅ Loaded environment variables from { dotenv_file } " )
94100 return dotenv_file
95101 else :
96- env_deferred_logger .warning (
102+ _env_deferred_logger .warning (
97103 f"⚠️ No .env file found. We recommend you to configure the program with ~/.config/{ app_name } /.env.\n "
98104 "⚠️ You can create a template with `ml-project config`."
99105 )
@@ -122,20 +128,20 @@ def update_data_dirs(data_dir: str | PathLike):
122128 else :
123129 from platformdirs import user_data_path
124130
125- env_deferred_logger .warning (
131+ _env_deferred_logger .warning (
126132 "⚠️ No data directory is set. "
127133 f"We recommend you to set the data directory with the environment variable { app_name_upper } _DATA_DIR."
128134 )
129135 data_dir = user_data_path (__name__ )
130- env_deferred_logger .warning (f"⚠️ Using { data_dir } as the data directory." )
136+ _env_deferred_logger .warning (f"⚠️ Using { data_dir } as the data directory." )
131137else :
132138 data_dir = Path (data_dir )
133139 if not data_dir .absolute ():
134140 if PROJECT_DIR is not None :
135141 data_dir = PROJECT_DIR / data_dir
136142 else :
137143 data_dir = Path .cwd () / data_dir
138- env_deferred_logger .warning (
144+ _env_deferred_logger .warning (
139145 "⚠️ It is recommended to set the data directory with an absolute path.\n "
140146 f"Using { data_dir } as the data directory."
141147 )
@@ -146,3 +152,158 @@ def update_data_dirs(data_dir: str | PathLike):
146152default_log_level = os .environ .get (f"{ app_name_upper } _LOG_LEVEL" )
147153if default_log_level is None :
148154 default_log_level = "INFO"
155+
156+ # ┌───────────────────────────────────────────────┐
157+ # │ setup_logging() │
158+ # └───────────────────────────────────────────────┘
159+ import inspect
160+ import logging
161+ import os
162+ from collections .abc import Sequence
163+ from datetime import datetime , timezone
164+ from os import PathLike
165+ from pathlib import Path
166+
167+ from rich .console import Console
168+ from rich .logging import RichHandler
169+ from rich .theme import Theme
170+
171+ logger = logging .getLogger (__name__ )
172+
173+ console = Console (
174+ theme = Theme (
175+ {
176+ "logging.level.error" : "bold red blink" ,
177+ "logging.level.critical" : "red blink" ,
178+ "logging.level.warning" : "yellow" ,
179+ "logging.level.success" : "green" ,
180+ }
181+ )
182+ )
183+
184+
185+ def setup_logging (
186+ console_level : int | str = default_log_level ,
187+ log_dir : str | PathLike | None = None ,
188+ output_files : Sequence [str ] = (
189+ "{date:%Y%m%d-%H%M%S}-{name}-{levelname}-{version}.log" ,
190+ ),
191+ file_levels : Sequence [int ] = (logging .INFO ,),
192+ * ,
193+ log_init_messages : bool = True ,
194+ console_formatter : logging .Formatter | None = None ,
195+ file_formatter : logging .Formatter | None = None ,
196+ ):
197+ r"""
198+ Setup logging with RichHandler and FileHandler.
199+
200+ You should call this function at the beginning of your script.
201+
202+ Args:
203+ console_level: Logging level for console. Defaults to INFO or env var {app_name_upper}_LOG_LEVEL.
204+ log_dir: Directory to save log files. If None, only console logging is enabled. Usually set to LOG_DIR.
205+ output_files: List of output file paths, relative to log_dir. Only applies if log_dir is not None.
206+ file_levels: List of logging levels for each output file. Only applies if log_dir is not None.
207+ log_init_messages: Whether to log the initialisation messages.
208+ """
209+ assert len (output_files ) == len (
210+ file_levels
211+ ), "output_files and file_levels must have the same length"
212+
213+ if log_dir is None :
214+ output_files = []
215+ file_levels = []
216+ else :
217+ log_dir = Path (log_dir )
218+
219+ # NOTE: Initialise with NOTSET level and null device, and add stream handler separately.
220+ # This way, the root logging level is NOTSET (log all), and we can customise each handler's behaviour.
221+ # If we set the level during the initialisation, it will affect to ALL streams,
222+ # so the file stream cannot be more verbose (lower level) than the console stream.
223+ logging .basicConfig (
224+ format = "" ,
225+ level = logging .NOTSET ,
226+ stream = open (os .devnull , "w" ), # noqa: SIM115
227+ )
228+
229+ # If you want to suppress logs from other modules, set their level to WARNING or higher
230+ # logging.getLogger('slowfast.utils.checkpoint').setLevel(logging.WARNING)
231+
232+ console_handler = RichHandler (
233+ level = console_level ,
234+ show_time = True ,
235+ show_level = True ,
236+ show_path = True ,
237+ rich_tracebacks = True ,
238+ tracebacks_show_locals = True ,
239+ console = console ,
240+ )
241+
242+ if console_formatter is None :
243+ console_format = logging .Formatter (
244+ fmt = "%(name)s - %(message)s" ,
245+ datefmt = "%H:%M:%S" ,
246+ )
247+ else :
248+ console_format = console_formatter
249+ console_handler .setFormatter (console_format )
250+
251+ if file_formatter is None :
252+ f_format = logging .Formatter (
253+ fmt = "%(asctime)s - %(name)s: %(lineno)4d - %(levelname)s - %(message)s" ,
254+ datefmt = "%y/%m/%d %H:%M:%S" ,
255+ )
256+ else :
257+ f_format = file_formatter
258+
259+ function_caller_module = inspect .getmodule (inspect .stack ()[1 ][0 ])
260+ if function_caller_module is None :
261+ name_or_path = "unknown"
262+ elif function_caller_module .__name__ == "__main__" :
263+ if function_caller_module .__file__ is None :
264+ name_or_path = function_caller_module .__name__
265+ elif PROJECT_DIR is not None :
266+ # Called from files in the project directory.
267+ # Instead of using the __name__ == "__main__", infer the module name from the file path.
268+ name_or_path = function_caller_module .__file__ .replace (
269+ str (PROJECT_DIR ) + "/" , ""
270+ ).replace ("/" , "." )
271+ # Remove .py extension
272+ name_or_path = Path (name_or_path ).with_suffix ("" )
273+ else :
274+ # Called from somewhere outside the project directory.
275+ # Use the script name, like "script.py"
276+ name_or_path = Path (function_caller_module .__file__ ).name
277+ else :
278+ name_or_path = function_caller_module .__name__
279+
280+ log_path_map = {
281+ "name" : name_or_path ,
282+ "version" : __version__ ,
283+ "date" : datetime .now (timezone .utc ),
284+ }
285+
286+ root_logger = logging .getLogger ()
287+ root_logger .addHandler (console_handler )
288+
289+ if log_init_messages :
290+ logger .info (f"{ package_name } { __version__ } " )
291+ _env_deferred_logger .flush (logger )
292+
293+ if log_dir is not None :
294+ log_paths = []
295+ for output_file , file_level in zip (output_files , file_levels , strict = True ):
296+ log_path_map ["levelname" ] = logging ._levelToName [file_level ]
297+ log_path = log_dir / output_file .format_map (log_path_map )
298+ log_path .parent .mkdir (parents = True , exist_ok = True )
299+
300+ f_handler = logging .FileHandler (log_path )
301+ f_handler .setLevel (file_level )
302+ f_handler .setFormatter (f_format )
303+
304+ # Add handlers to the logger
305+ root_logger .addHandler (f_handler )
306+
307+ if log_init_messages :
308+ for log_path in log_paths :
309+ logger .info (f"Logging to { log_path } " )
0 commit comments