Skip to content

Commit b013904

Browse files
committed
rimport: Use logging instead of print().
1 parent 6de2a70 commit b013904

4 files changed

Lines changed: 208 additions & 56 deletions

File tree

rimport

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Do `rimport --help` for more information.
99
from __future__ import annotations
1010

1111
import argparse
12+
import logging
1213
import os
1314
import pwd
1415
import shutil
@@ -26,6 +27,9 @@ STAGE_OWNER = "cesmdata"
2627
INDENT = " "
2728
INPUTDATA_URL = "https://osdf-data.gdex.ucar.edu/ncar/gdex/d651077/cesmdata/inputdata"
2829

30+
# Configure logging
31+
logger = logging.getLogger(__name__)
32+
2933

3034
def build_parser() -> argparse.ArgumentParser:
3135
"""Build and configure the argument parser for rimport.
@@ -177,7 +181,7 @@ def stage_data(
177181
f"Source is a symlink, but target ({src.resolve()}) is outside staging directory "
178182
f"({staging_root})"
179183
)
180-
print(f"{INDENT}File is already published and linked.")
184+
logger.info("%sFile is already published and linked.", INDENT)
181185
print_can_file_be_downloaded(
182186
can_file_be_downloaded(src.resolve(), staging_root)
183187
)
@@ -200,21 +204,19 @@ def stage_data(
200204
dst = staging_root / rel
201205

202206
if dst.exists():
203-
print(f"{INDENT}File is already published but NOT linked; do")
204-
print(f"{2*INDENT}relink.py {rel}")
205-
print(f"{INDENT}to resolve.")
206-
print_can_file_be_downloaded(
207-
can_file_be_downloaded(rel, staging_root)
208-
)
207+
logger.info("%sFile is already published but NOT linked; do", INDENT)
208+
logger.info("%srelink.py %s", 2 * INDENT, rel)
209+
logger.info("%sto resolve.", INDENT)
210+
print_can_file_be_downloaded(can_file_be_downloaded(rel, staging_root))
209211
return
210212

211213
if check:
212-
print(f"{INDENT}File is not already published")
214+
logger.info("%sFile is not already published", INDENT)
213215
return
214216

215217
dst.parent.mkdir(parents=True, exist_ok=True)
216218
shutil.copy2(src, dst)
217-
print(f"{INDENT}[rimport] staged {src} -> {dst}")
219+
logger.info("%s[rimport] staged %s -> %s", INDENT, src, dst)
218220

219221

220222
def ensure_running_as(target_user: str, argv: list[str]) -> None:
@@ -237,20 +239,18 @@ def ensure_running_as(target_user: str, argv: list[str]) -> None:
237239
try:
238240
target_uid = pwd.getpwnam(target_user).pw_uid
239241
except KeyError as exc:
240-
print(
241-
f"rimport: target user '{target_user}' not found on this system",
242-
file=sys.stderr,
243-
)
242+
logger.error("rimport: target user '%s' not found on this system", target_user)
244243
raise SystemExit(2) from exc
245244

246245
if os.geteuid() != target_uid:
247246
try:
248247
assert sys.stdin.isatty()
249248
except AssertionError as exc:
250-
print(
251-
f"rimport: need interactive TTY to authenticate as '{target_user}' (2FA).\n"
252-
f" Try: sudo -u {target_user} rimport …",
253-
file=sys.stderr,
249+
logger.error(
250+
"rimport: need interactive TTY to authenticate as '%s' (2FA).\n"
251+
" Try: sudo -u %s rimport …",
252+
target_user,
253+
target_user,
254254
)
255255
raise SystemExit(2) from exc
256256
# Re-exec under target user; this invokes sudo’s normal password/2FA flow.
@@ -310,9 +310,37 @@ def print_can_file_be_downloaded(file_can_be_downloaded: bool):
310310
file_can_be_downloaded: Boolean indicating if the file can be downloaded.
311311
"""
312312
if file_can_be_downloaded:
313-
print(f"{INDENT}File is available for download.")
313+
logger.info("%sFile is available for download.", INDENT)
314314
else:
315-
print(f"{INDENT}File is not (yet) available for download.")
315+
logger.info("%sFile is not (yet) available for download.", INDENT)
316+
317+
318+
def configure_logging() -> None:
319+
"""Configure logging to send INFO/WARNING to stdout and ERROR/CRITICAL to stderr.
320+
321+
Sets up two handlers:
322+
- INFO handler: Sends INFO and WARNING level messages to stdout
323+
- ERROR handler: Sends ERROR and CRITICAL level messages to stderr
324+
325+
Both handlers use simple message-only formatting without timestamps or level names.
326+
"""
327+
logger.setLevel(logging.INFO)
328+
329+
# Handler for INFO and WARNING level messages -> stdout
330+
info_handler = logging.StreamHandler(sys.stdout)
331+
info_handler.setLevel(logging.INFO)
332+
info_handler.addFilter(lambda record: record.levelno < logging.ERROR)
333+
info_handler.setFormatter(logging.Formatter("%(message)s"))
334+
335+
# Handler for ERROR and CRITICAL level messages -> stderr
336+
error_handler = logging.StreamHandler(sys.stderr)
337+
error_handler.setLevel(logging.ERROR)
338+
error_handler.setFormatter(logging.Formatter("%(message)s"))
339+
340+
# Clear any existing handlers and add our custom ones
341+
logger.handlers.clear()
342+
logger.addHandler(info_handler)
343+
logger.addHandler(error_handler)
316344

317345

318346
def main(argv: List[str] | None = None) -> int:
@@ -337,6 +365,8 @@ def main(argv: List[str] | None = None) -> int:
337365
1: One or more files failed to stage (errors printed to stderr).
338366
2: Fatal error (missing inputdata directory, missing file list, etc.).
339367
"""
368+
configure_logging()
369+
340370
parser = build_parser()
341371
args = parser.parse_args(argv)
342372

@@ -348,7 +378,7 @@ def main(argv: List[str] | None = None) -> int:
348378

349379
root = Path(args.inputdata).expanduser().resolve()
350380
if not root.exists():
351-
print(f"rimport: inputdata directory does not exist: {root}", file=sys.stderr)
381+
logger.error("rimport: inputdata directory does not exist: %s", root)
352382
return 2
353383

354384
# Determine the list of relative filenames to handle
@@ -357,11 +387,11 @@ def main(argv: List[str] | None = None) -> int:
357387
else:
358388
list_path = Path(args.filelist).expanduser().resolve()
359389
if not list_path.exists():
360-
print(f"rimport: list file not found: {list_path}", file=sys.stderr)
390+
logger.error("rimport: list file not found: %s", list_path)
361391
return 2
362392
relnames = read_filelist(list_path)
363393
if not relnames:
364-
print(f"rimport: no filenames found in list: {list_path}", file=sys.stderr)
394+
logger.error("rimport: no filenames found in list: %s", list_path)
365395
return 2
366396

367397
# Resolve to full paths (keep accepting absolute names too)
@@ -370,13 +400,13 @@ def main(argv: List[str] | None = None) -> int:
370400
# Execute the new action per file
371401
errors = 0
372402
for p in paths:
373-
print(f"'{p}':")
403+
logger.info("'%s':", p)
374404
try:
375405
stage_data(p, root, staging_root, args.check)
376406
except Exception as e: # pylint: disable=broad-exception-caught
377407
# General Exception keeps CLI robust for batch runs
378408
errors += 1
379-
print(f"{INDENT}rimport: error processing {p}: {e}", file=sys.stderr)
409+
logger.error("%srimport: error processing %s: %s", INDENT, p, e)
380410

381411
return 0 if errors == 0 else 1
382412

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Tests for configure_logging() function in rimport script.
3+
"""
4+
5+
import os
6+
import sys
7+
import importlib.util
8+
import logging
9+
from importlib.machinery import SourceFileLoader
10+
11+
import pytest
12+
13+
# Import rimport module from file without .py extension
14+
rimport_path = os.path.join(
15+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
16+
"rimport",
17+
)
18+
loader = SourceFileLoader("rimport", rimport_path)
19+
spec = importlib.util.spec_from_loader("rimport", loader)
20+
if spec is None:
21+
raise ImportError(f"Could not create spec for rimport from {rimport_path}")
22+
rimport = importlib.util.module_from_spec(spec)
23+
loader.exec_module(rimport)
24+
25+
26+
class TestConfigureLogging:
27+
"""Test suite for configure_logging() function."""
28+
29+
@pytest.fixture(autouse=True)
30+
def cleanup_logger(self):
31+
"""Clean up logger handlers after each test."""
32+
yield
33+
# Clear handlers after test
34+
rimport.logger.handlers.clear()
35+
36+
def test_sets_logger_level_to_info(self):
37+
"""Test that configure_logging sets the logger level to INFO."""
38+
rimport.configure_logging()
39+
assert rimport.logger.level == logging.INFO
40+
41+
def test_creates_two_handlers(self):
42+
"""Test that configure_logging creates exactly two handlers."""
43+
rimport.configure_logging()
44+
assert len(rimport.logger.handlers) == 2
45+
46+
def test_info_handler_goes_to_stdout(self, capsys):
47+
"""Test that INFO level messages go to stdout."""
48+
rimport.configure_logging()
49+
rimport.logger.info("Test info message")
50+
51+
captured = capsys.readouterr()
52+
assert "Test info message" in captured.out
53+
assert captured.err == ""
54+
55+
def test_warning_handler_goes_to_stdout(self, capsys):
56+
"""Test that WARNING level messages go to stdout."""
57+
rimport.configure_logging()
58+
rimport.logger.warning("Test warning message")
59+
60+
captured = capsys.readouterr()
61+
assert "Test warning message" in captured.out
62+
assert captured.err == ""
63+
64+
def test_error_handler_goes_to_stderr(self, capsys):
65+
"""Test that ERROR level messages go to stderr."""
66+
rimport.configure_logging()
67+
rimport.logger.error("Test error message")
68+
69+
captured = capsys.readouterr()
70+
assert captured.out == ""
71+
assert "Test error message" in captured.err
72+
73+
def test_critical_handler_goes_to_stderr(self, capsys):
74+
"""Test that CRITICAL level messages go to stderr."""
75+
rimport.configure_logging()
76+
rimport.logger.critical("Test critical message")
77+
78+
captured = capsys.readouterr()
79+
assert captured.out == ""
80+
assert "Test critical message" in captured.err
81+
82+
def test_debug_messages_not_logged_if_info(self, capsys):
83+
"""Test that DEBUG level messages are not logged if logger level is INFO."""
84+
rimport.configure_logging()
85+
rimport.logger.debug("Test debug message")
86+
87+
captured = capsys.readouterr()
88+
assert captured.out == ""
89+
assert captured.err == ""
90+
91+
def test_clears_existing_handlers(self):
92+
"""Test that configure_logging clears any existing handlers."""
93+
# Add a dummy handler
94+
from io import StringIO
95+
dummy_handler = logging.StreamHandler(StringIO())
96+
rimport.logger.addHandler(dummy_handler)
97+
assert len(rimport.logger.handlers) >= 1
98+
99+
# Configure logging
100+
rimport.configure_logging()
101+
102+
# Verify old handlers were cleared and new ones added
103+
assert len(rimport.logger.handlers) == 2
104+
assert dummy_handler not in rimport.logger.handlers
105+
106+
def test_formatter_uses_message_only(self, capsys):
107+
"""Test that the formatter outputs only the message without level/timestamp."""
108+
rimport.configure_logging()
109+
rimport.logger.info("Simple message")
110+
111+
captured = capsys.readouterr()
112+
output = captured.out.strip()
113+
assert output == "Simple message"
114+
assert "INFO" not in output
115+
116+
def test_multiple_calls_dont_duplicate_handlers(self):
117+
"""Test that calling configure_logging multiple times doesn't duplicate handlers."""
118+
rimport.configure_logging()
119+
assert len(rimport.logger.handlers) == 2
120+
121+
rimport.configure_logging()
122+
assert len(rimport.logger.handlers) == 2 # Still 2, not 4
123+
124+
rimport.configure_logging()
125+
assert len(rimport.logger.handlers) == 2 # Still 2, not 6

tests/rimport/test_ensure_running_as.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ def test_does_nothing_when_already_running_as_target_user(self):
4242
with patch("sys.stdin.isatty") as mock_isatty:
4343
with patch("os.execvp") as mock_execvp:
4444
# Should not raise or exec
45-
rimport.ensure_running_as("testuser", ["rimport", "-file", "test.nc"])
45+
rimport.ensure_running_as(
46+
"testuser", ["rimport", "-file", "test.nc"]
47+
)
4648

4749
# Verify stdin.isatty and os.execvp were NOT called
4850
mock_isatty.assert_not_called()
@@ -77,19 +79,18 @@ def test_execs_sudo_when_different_user_and_interactive(self):
7779
assert call_args[1][3] == "--"
7880
assert call_args[1][4:] == ["rimport", "-file", "test.nc"]
7981

80-
def test_error_message_for_nonexistent_user(self, capsys):
82+
def test_error_message_for_nonexistent_user(self, caplog):
8183
"""Test that appropriate error message is shown for nonexistent user."""
8284
# Mock pwd.getpwnam to raise KeyError
8385
with patch("pwd.getpwnam", side_effect=KeyError("user not found")):
8486
with pytest.raises(SystemExit) as exc_info:
8587
rimport.ensure_running_as("baduser", ["rimport", "-file", "test.nc"])
8688

8789
assert exc_info.value.code == 2
88-
captured = capsys.readouterr()
89-
assert "baduser" in captured.err
90-
assert "not found" in captured.err
90+
assert "baduser" in caplog.text
91+
assert "not found" in caplog.text
9192

92-
def test_error_message_for_non_interactive(self, capsys):
93+
def test_error_message_for_non_interactive(self, caplog):
9394
"""Test that appropriate error message is shown when not interactive."""
9495
current_uid = os.geteuid()
9596
different_uid = current_uid + 1000
@@ -108,6 +109,5 @@ def test_error_message_for_non_interactive(self, capsys):
108109
)
109110

110111
assert exc_info.value.code == 2
111-
captured = capsys.readouterr()
112-
assert "interactive TTY" in captured.err
113-
assert "2FA" in captured.err
112+
assert "interactive TTY" in caplog.text
113+
assert "2FA" in caplog.text

0 commit comments

Comments
 (0)