Skip to content

Commit a909596

Browse files
committed
Several improvements with logging
Fixes #3 Signed-off-by: Pedro Algarvio <[email protected]>
1 parent ba705a1 commit a909596

File tree

6 files changed

+165
-31
lines changed

6 files changed

+165
-31
lines changed

changelog/3.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Several improvements with logging

setup.cfg

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ per-file-ignores =
8585
# D100 Missing docstring in public module
8686
# D103 Missing docstring in public function
8787
noxfile.py: D100,D102,D103,D107,D212,E501
88+
# D101 Missing docstring in public class
89+
# D102 Missing docstring in public method
90+
src/ptscripts/logs.py: D101,D102
8891
# D100 Missing docstring in public module
8992
# D101 Missing docstring in public class
9093
# D102 Missing docstring in public method

src/ptscripts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import ptscripts.logs
34
from ptscripts.parser import command_group
45
from ptscripts.parser import Context
56

src/ptscripts/__main__.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,9 @@
33
import logging
44
import sys
55

6-
from rich.console import Console
7-
from rich.logging import RichHandler
8-
96
from ptscripts.parser import Parser
107

11-
FORMAT = "%(message)s"
12-
logging.basicConfig(
13-
level=logging.INFO,
14-
format=FORMAT,
15-
datefmt="[%X]",
16-
handlers=[
17-
RichHandler(
18-
console=Console(stderr=True),
19-
markup=True,
20-
rich_tracebacks=True,
21-
),
22-
],
23-
)
8+
log = logging.getLogger(__name__)
249

2510

2611
def main():
@@ -29,6 +14,7 @@ def main():
2914
"""
3015
parser = Parser()
3116
cwd = str(parser.repo_root)
17+
log.debug(f"Searching for tools in {cwd}")
3218
if cwd in sys.path:
3319
sys.path.remove(cwd)
3420
sys.path.insert(0, cwd)

src/ptscripts/logs.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
import sys
6+
7+
STDOUT = sys.maxsize
8+
logging.STDOUT = STDOUT # type: ignore[attr-defined]
9+
STDERR = sys.maxsize - 1
10+
logging.STDERR = STDERR # type: ignore[attr-defined]
11+
logging.addLevelName(STDOUT, "STDOUT")
12+
logging.addLevelName(STDERR, "STDERR")
13+
14+
15+
class LevelFilter(logging.Filter):
16+
def __init__(
17+
self,
18+
level: int | None = None,
19+
not_levels: list[int] | tuple[int, ...] | None = None,
20+
):
21+
self.level = level
22+
self.not_levels = not_levels or []
23+
24+
def filter(self, record: logging.LogRecord) -> bool:
25+
if self.not_levels and record.levelno in self.not_levels:
26+
return False
27+
if self.level and record.levelno != self.level:
28+
return False
29+
return True
30+
31+
32+
class DuplicateTimesFormatter(logging.Formatter):
33+
def __init__(self, *args, **kwargs):
34+
super().__init__(*args, **kwargs)
35+
self._last_timestamp: str | None = None
36+
37+
def formatTime(self, record, datefmt=None):
38+
formatted_time = super().formatTime(record, datefmt=datefmt)
39+
if self._last_timestamp and formatted_time == self._last_timestamp:
40+
formatted_time = " " * len(formatted_time)
41+
else:
42+
self._last_timestamp = formatted_time
43+
return formatted_time
44+
45+
def format(self, record):
46+
if "\r\n" in record.msg:
47+
line_split = "\r\n"
48+
else:
49+
line_split = "\n"
50+
lines = record.msg.replace("\r\n", "\n").splitlines()
51+
outlines = [lines.pop(0)]
52+
if self._last_timestamp:
53+
prefix = " " * len(self._last_timestamp)
54+
else:
55+
prefix = " " * len(self.formatTime(record, self.datefmt))
56+
self._last_timestamp = None
57+
outlines.extend([f"{prefix}{line.rstrip()}" for line in lines])
58+
record.msg = line_split.join(outlines).rstrip()
59+
if line_split.endswith("\r\n"):
60+
record.msg += "\r"
61+
return super().format(record)
62+
63+
64+
class LoggingClass(logging.Logger):
65+
def stderr(self, msg, *args, **kwargs):
66+
return self.log(STDERR, msg, *args, **kwargs)
67+
68+
def stdout(self, msg, *args, **kwargs):
69+
return self.log(STDOUT, msg, *args, **kwargs)
70+
71+
72+
# Override the python's logging logger class as soon as this module is imported
73+
if logging.getLoggerClass() is not LoggingClass:
74+
logging.setLoggerClass(LoggingClass)
75+
76+
# Reset logging handlers
77+
logging.root.handlers.clear()
78+
logging.root.setLevel(logging.INFO)
79+
80+
NO_TIMESTAMP_FORMATTER = logging.Formatter(fmt="%(message)s")
81+
TIMESTAMP_FORMATTER = DuplicateTimesFormatter(fmt="%(asctime)s%(message)s", datefmt="[%H:%M:%S] ")
82+
83+
DEFAULT_FORMATTER: logging.Formatter | DuplicateTimesFormatter
84+
if "CI" in os.environ:
85+
DEFAULT_FORMATTER = TIMESTAMP_FORMATTER
86+
else:
87+
DEFAULT_FORMATTER = NO_TIMESTAMP_FORMATTER
88+
STDERR_HANDLER = logging.StreamHandler(stream=sys.stderr)
89+
STDERR_HANDLER.setLevel(STDERR)
90+
STDERR_HANDLER.addFilter(LevelFilter(level=STDERR))
91+
92+
STDOUT_HANDLER = logging.StreamHandler(stream=sys.stdout)
93+
STDOUT_HANDLER.setLevel(STDOUT)
94+
STDOUT_HANDLER.addFilter(LevelFilter(level=STDOUT))
95+
96+
ROOT_HANDLER = logging.StreamHandler(stream=sys.stderr)
97+
ROOT_HANDLER.setLevel(logging.DEBUG)
98+
ROOT_HANDLER.addFilter(LevelFilter(not_levels=(STDERR, STDOUT)))
99+
100+
for handler in (ROOT_HANDLER, STDERR_HANDLER, STDOUT_HANDLER):
101+
handler.setFormatter(DEFAULT_FORMATTER)
102+
logging.root.addHandler(handler)
103+
104+
105+
def include_timestamps() -> bool:
106+
"""
107+
Return True if any of the configured logging handlers includes timestamps.
108+
"""
109+
for handler in logging.root.handlers:
110+
if handler.formatter is TIMESTAMP_FORMATTER:
111+
return True
112+
return False

src/ptscripts/parser.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818
from typing import TYPE_CHECKING
1919
from typing import TypedDict
2020

21+
import rich
2122
from rich.console import Console
2223
from rich.theme import Theme
2324

25+
from ptscripts import logs
26+
2427
if TYPE_CHECKING:
2528
from argparse import ArgumentParser
2629
from argparse import _SubParsersAction
@@ -69,6 +72,8 @@ def __init__(self, parser: Parser):
6972
"log-error": "bold red",
7073
"exit-ok": "green",
7174
"exit-failure": "bold red",
75+
"logging.level.stdout": "dim blue",
76+
"logging.level.stderr": "dim red",
7277
}
7378
)
7479
console_kwargs = {
@@ -79,6 +84,7 @@ def __init__(self, parser: Parser):
7984
console_kwargs["force_interactive"] = False
8085
self.console = Console(stderr=True, **console_kwargs)
8186
self.console_stdout = Console(**console_kwargs)
87+
rich.reconfigure(stderr=True, **console_kwargs)
8288

8389
def print(self, *args, **kwargs):
8490
"""
@@ -148,16 +154,33 @@ def __new__(cls):
148154
epilog="These tools are discovered under `<repo-root>/tools`.",
149155
allow_abbrev=False,
150156
)
151-
group = instance.parser.add_mutually_exclusive_group()
152-
# group.add_argument(
153-
# "--quiet",
154-
# "-q",
155-
# dest="quiet",
156-
# action="store_true",
157-
# default=False,
158-
# help="Show info messages",
159-
# )
160-
group.add_argument(
157+
log_group = instance.parser.add_argument_group("Logging")
158+
timestamp_meg = log_group.add_mutually_exclusive_group()
159+
timestamp_meg.add_argument(
160+
"--timestamps",
161+
"--ts",
162+
action="store_true",
163+
help="Add time stamps to logs",
164+
dest="timestamps",
165+
)
166+
timestamp_meg.add_argument(
167+
"--no-timestamps",
168+
"--nts",
169+
action="store_false",
170+
default=True,
171+
help="Remove time stamps from logs",
172+
dest="timestamps",
173+
)
174+
level_group = log_group.add_mutually_exclusive_group()
175+
level_group.add_argument(
176+
"--quiet",
177+
"-q",
178+
dest="quiet",
179+
action="store_true",
180+
default=False,
181+
help="Disable logging",
182+
)
183+
level_group.add_argument(
161184
"--debug",
162185
"-d",
163186
action="store_true",
@@ -176,12 +199,19 @@ def parse_args(self):
176199
Parse CLI.
177200
"""
178201
options = self.parser.parse_args()
179-
self.options = options
180-
logging.root.setLevel(logging.INFO)
181-
if options.debug:
202+
if options.quiet:
203+
logging.root.setLevel(logging.CRITICAL + 1)
204+
elif options.debug:
182205
logging.root.setLevel(logging.DEBUG)
183-
# elif options.quiet:
184-
# logging.root.setLevel(logging.FATAL)
206+
else:
207+
logging.root.setLevel(logging.INFO)
208+
if options.timestamps:
209+
for handler in logging.root.handlers:
210+
handler.setFormatter(logs.TIMESTAMP_FORMATTER)
211+
else:
212+
for handler in logging.root.handlers:
213+
handler.setFormatter(logs.NO_TIMESTAMP_FORMATTER)
214+
self.options = options
185215
if "func" not in options:
186216
self.context.exit(1, "No command was passed.")
187217
log.debug(f"CLI parsed options {options}")
@@ -327,6 +357,7 @@ def command(
327357
flags = kwargs.pop("flags", None) # type: ignore[misc]
328358
if flags is None:
329359
flags = [f"--{parameter.name.replace('_', '-')}"]
360+
log.debug("Adding Command %r. Flags: %s; KwArgs: %s", name, flags, kwargs)
330361
command.add_argument(*flags, **kwargs)
331362
command.set_defaults(func=partial(self, func))
332363
return func

0 commit comments

Comments
 (0)