Skip to content

Commit cf178a5

Browse files
committed
refactor!: ml_project.utils.log.setup_logging -> ml_project.setup_logging
1 parent 3d418ea commit cf178a5

File tree

5 files changed

+177
-190
lines changed

5 files changed

+177
-190
lines changed

docs/python/logging.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ rich.traceback.install(show_locals=True)
4040

4141
import logging
4242

43-
from ml_project import LOG_DIR
44-
from ml_project.utils.log import setup_logging
43+
from ml_project import LOG_DIR, setup_logging
4544

4645
logger = logging.getLogger(__name__)
4746

@@ -64,7 +63,7 @@ if __name__ == "__main__":
6463

6564
본 template에서는 logging을 쉽게 설정할 수 있는 함수를 제공합니다.
6665

67-
### ::: ml_project.utils.log.setup_logging
66+
### ::: ml_project.setup_logging
6867
options:
6968
show_root_heading: true
7069

src/ml_project/__init__.py

Lines changed: 173 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
The package could be installed as develop mode (`pip install -e .`) or normally (`pip install .`),
55
so 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.
@@ -17,16 +17,18 @@
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
2624
You 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
@@ -46,13 +48,17 @@
4648
app_name_upper = app_name.upper()
4749
package_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.
5157
PROJECT_DIR: Path | None = Path(__file__).parent.parent.parent
5258
if PROJECT_DIR.name.startswith("python3."):
5359
PROJECT_DIR = None
5460

55-
env_deferred_logger = DeferredLogger()
61+
_env_deferred_logger = DeferredLogger()
5662

5763

5864
def 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.")
131137
else:
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):
146152
default_log_level = os.environ.get(f"{app_name_upper}_LOG_LEVEL")
147153
if 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}")

src/ml_project/cli/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ def common(
3535

3636
@app.command()
3737
def health():
38+
from .. import setup_logging
3839
from ..health import main as health_main
39-
from ..utils.log import setup_logging
4040

4141
setup_logging(log_dir=None)
4242
health_main()

0 commit comments

Comments
 (0)