Skip to content

Commit cb8ec5c

Browse files
[Feature]: Default CLI log level is DEBUG; WARNING and above go to STDOUT, DEBUG logs to a file #2939
Review feedback
1 parent f22bdb6 commit cb8ec5c

2 files changed

Lines changed: 116 additions & 6 deletions

File tree

src/dstack/_internal/cli/utils/common.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
from datetime import datetime, timezone
4+
from pathlib import Path
45
from typing import Any, Dict, Union
56

67
from rich.console import Console
@@ -37,10 +38,8 @@ def cli_error(e: DstackError) -> CLIError:
3738
return CLIError(*e.args)
3839

3940

40-
def configure_logging():
41-
dstack_logger = logging.getLogger("dstack")
42-
dstack_logger.handlers.clear()
43-
41+
def _get_cli_log_file() -> Path:
42+
"""Get the CLI log file path, rotating the previous log if needed."""
4443
log_dir = get_dstack_dir() / "logs" / "cli"
4544
log_file = log_dir / "latest.log"
4645

@@ -59,6 +58,16 @@ def configure_logging():
5958

6059
log_file.rename(rotated_file)
6160

61+
log_dir.mkdir(parents=True, exist_ok=True)
62+
return log_file
63+
64+
65+
def configure_logging():
66+
dstack_logger = logging.getLogger("dstack")
67+
dstack_logger.handlers.clear()
68+
69+
log_file = _get_cli_log_file()
70+
6271
level_names = logging.getLevelNamesMapping()
6372
stdout_level_name = os.getenv("DSTACK_CLI_LOG_LEVEL", "INFO").upper()
6473
stdout_level = level_names[stdout_level_name]
@@ -72,8 +81,6 @@ def configure_logging():
7281
file_level_name = os.getenv("DSTACK_CLI_FILE_LOG_LEVEL", "DEBUG").upper()
7382
file_level = level_names[file_level_name]
7483

75-
log_dir.mkdir(parents=True, exist_ok=True)
76-
7784
file_handler = logging.FileHandler(log_file)
7885
file_handler.setFormatter(
7986
logging.Formatter(
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import os
2+
from datetime import datetime, timedelta, timezone
3+
from pathlib import Path
4+
from typing import Generator
5+
from unittest.mock import patch
6+
7+
import pytest
8+
9+
from dstack._internal.cli.utils.common import _get_cli_log_file
10+
11+
12+
@pytest.fixture
13+
def mock_dstack_dir(tmp_path: Path) -> Generator[Path, None, None]:
14+
with patch("dstack._internal.cli.utils.common.get_dstack_dir") as mock:
15+
mock.return_value = tmp_path
16+
yield tmp_path
17+
18+
19+
class TestGetCliLogFile:
20+
def test_no_existing_dir(self, mock_dstack_dir: Path):
21+
log_dir = mock_dstack_dir / "logs" / "cli"
22+
expected_log_file = log_dir / "latest.log"
23+
assert not log_dir.exists()
24+
25+
result = _get_cli_log_file()
26+
27+
assert log_dir.exists()
28+
assert result == expected_log_file
29+
30+
def test_no_rotation_needed_for_today_file(self, mock_dstack_dir: Path):
31+
log_dir = mock_dstack_dir / "logs" / "cli"
32+
log_dir.mkdir(parents=True, exist_ok=True)
33+
latest_log = log_dir / "latest.log"
34+
latest_log.touch()
35+
36+
result = _get_cli_log_file()
37+
38+
assert result == latest_log
39+
assert latest_log.exists(), "latest.log should not have been renamed"
40+
41+
@patch("dstack._internal.cli.utils.common.datetime")
42+
def test_simple_rotation(self, mock_datetime, mock_dstack_dir: Path):
43+
# Mock "now" to be a specific date
44+
now = datetime(2023, 10, 27, 10, 0, 0, tzinfo=timezone.utc)
45+
mock_datetime.now.return_value = now
46+
# Ensure fromtimestamp still works correctly for the System Under Test
47+
mock_datetime.fromtimestamp.side_effect = lambda ts, tz: datetime.fromtimestamp(ts, tz)
48+
49+
log_dir = mock_dstack_dir / "logs" / "cli"
50+
log_dir.mkdir(parents=True, exist_ok=True)
51+
latest_log = log_dir / "latest.log"
52+
latest_log.touch()
53+
54+
# Set the modification time to yesterday
55+
yesterday = now - timedelta(days=1)
56+
mtime = yesterday.timestamp()
57+
os.utime(latest_log, (mtime, mtime))
58+
59+
# The expected rotated file name is based on the modification time (yesterday)
60+
date_str = yesterday.strftime("%Y-%m-%d")
61+
expected_rotated_log = log_dir / f"{date_str}.log"
62+
63+
result = _get_cli_log_file()
64+
65+
assert result == log_dir / "latest.log"
66+
assert not latest_log.exists(), "The original latest.log should have been renamed"
67+
assert expected_rotated_log.exists(), "The log file should have been rotated"
68+
69+
@patch("dstack._internal.cli.utils.common.datetime")
70+
def test_rotation_with_conflict(self, mock_datetime, mock_dstack_dir: Path):
71+
now = datetime(2023, 10, 27, 10, 0, 0, tzinfo=timezone.utc)
72+
yesterday = now - timedelta(days=1)
73+
mock_datetime.now.return_value = now
74+
mock_datetime.fromtimestamp.side_effect = lambda ts, tz: datetime.fromtimestamp(ts, tz)
75+
76+
log_dir = mock_dstack_dir / "logs" / "cli"
77+
log_dir.mkdir(parents=True, exist_ok=True)
78+
79+
# Create the old 'latest.log' and set its modification time to yesterday
80+
latest_log = log_dir / "latest.log"
81+
latest_log.touch()
82+
mtime = yesterday.timestamp()
83+
os.utime(latest_log, (mtime, mtime))
84+
85+
# Create conflicting files that already exist from a previous rotation
86+
date_str = yesterday.strftime("%Y-%m-%d")
87+
conflicting_log_1 = log_dir / f"{date_str}.log"
88+
conflicting_log_1.touch()
89+
conflicting_log_2 = log_dir / f"{date_str}-1.log"
90+
conflicting_log_2.touch()
91+
92+
# We expect the file to be rotated to the next available counter
93+
expected_rotated_log = log_dir / f"{date_str}-2.log"
94+
95+
result = _get_cli_log_file()
96+
97+
assert result == log_dir / "latest.log"
98+
assert not latest_log.exists(), "The original latest.log should have been renamed"
99+
assert conflicting_log_1.exists(), "Existing rotated log should be untouched"
100+
assert conflicting_log_2.exists(), "Existing rotated log with counter should be untouched"
101+
assert expected_rotated_log.exists(), (
102+
"The log should have rotated to the next available counter"
103+
)

0 commit comments

Comments
 (0)