Skip to content

Commit f304d87

Browse files
rlermlasers
authored andcommitted
Set up standard logging module
This has the advantage of overall less custom code; as well as support for per-module configuration. This would enable a potential solution for #1479, since in the future it can allow per-module configuration of log levels. I expect this to mainly help module creators, allowing them to enable logging for only their module, while disabling all other messages. It also is easier to use, since the `logging` module's interface is generally simpler than `self.py3`. Here, I've made the decision to keep the message format as close as possible to the existing log messages.
1 parent bdf3639 commit f304d87

4 files changed

Lines changed: 145 additions & 66 deletions

File tree

docs/dev-guide/writing-modules.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,49 @@ Loadavg 1.41 1.61 1.82
811811
Loadavg 1.41 1.61 1.82
812812
^C
813813
```
814+
815+
## Logging
816+
817+
Modules are encouraged to use Python's standard
818+
[`logging`](https://docs.python.org/3/library/logging.config.html?highlight=logging#logging-config-dictschema)
819+
module for debugging. By default, logs will be written to
820+
[`syslog`](https://docs.python.org/3/library/logging.config.html?highlight=logging#logging-config-dictschema)
821+
with a minimum level of `INFO`.
822+
823+
Several existing modules will write to logs, with varying levels of details.
824+
Therefore, when debugging a specific module, it may be useful to show only the
825+
one you're interested in. This can be done by using the `--log-config` flag to
826+
pass a JSON file that configures logging. This file must be in the format
827+
specified in
828+
[`logging`'s configuration schema](https://docs.python.org/3/library/logging.config.html?highlight=logging#logging-config-dictschema).
829+
For example, to show only logs from your module in a `DEBUG` level, while
830+
keeping all others at `WARNING`, you can use:
831+
832+
```json
833+
{
834+
"version": 1,
835+
"handlers": {
836+
"file": {
837+
"class": "logging.handlers.RotatingFileHandler",
838+
"filename": "/tmp/py3status_log.log",
839+
"maxBytes": 2048,
840+
"formatter": "default"
841+
}
842+
},
843+
"formatters": {
844+
"default": {
845+
"validate": true,
846+
"format":"%(asctime)s %(levelname)s %(module)s %(message)s",
847+
"datefmt":"%Y-%m-%d %H:%M:%S"
848+
}
849+
},
850+
"loggers": {
851+
"root": {"handlers": ["file"], "level": "WARNING"},
852+
"<my_module>": {"level": "DEBUG"}
853+
}
854+
}
855+
```
856+
814857
## Publishing custom modules on PyPI
815858

816859
You can share your custom modules and make them available for py3status

py3status/argparsers.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,6 @@ def _format_action_invocation(self, action):
8282
metavar="FILE",
8383
type=Path,
8484
)
85-
parser.add_argument(
86-
"-d",
87-
"--debug",
88-
action="store_true",
89-
help="enable debug logging in syslog or log file if --log-file option is passed",
90-
)
9185
parser.add_argument(
9286
"-i",
9387
"--include",
@@ -97,15 +91,6 @@ def _format_action_invocation(self, action):
9791
metavar="PATH",
9892
type=Path,
9993
)
100-
parser.add_argument(
101-
"-l",
102-
"--log-file",
103-
action="store",
104-
dest="log_file",
105-
help="enable logging to FILE (this option is not set by default)",
106-
metavar="FILE",
107-
type=Path,
108-
)
10994
parser.add_argument(
11095
"-s",
11196
"--standalone",
@@ -157,6 +142,33 @@ def _format_action_invocation(self, action):
157142
help="specify window manager i3 or sway",
158143
)
159144

145+
logging_args = parser.add_argument_group()
146+
logging_args.add_argument(
147+
"-d",
148+
"--debug",
149+
action="store_true",
150+
help="enable debug logging in syslog or log file if --log-file option is passed",
151+
)
152+
logging_args.add_argument(
153+
"-l",
154+
"--log-file",
155+
action="store",
156+
dest="log_file",
157+
help="enable logging to FILE (this option is not set by default)",
158+
metavar="FILE",
159+
type=Path,
160+
)
161+
logging_args.add_argument(
162+
"--log-config",
163+
action="store",
164+
dest="log_config",
165+
help="path to a file that fully configures the 'logging' module. This "
166+
"must contain a JSON dictionary in the format expected by "
167+
"logging.config.dictConfig.",
168+
metavar="FILE",
169+
type=Path,
170+
)
171+
160172
# parse options, command, etc
161173
options = parser.parse_args()
162174

py3status/core.py

Lines changed: 66 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import importlib.metadata
2+
import logging
3+
import logging.config
4+
import logging.handlers
25
import sys
36
import time
47
from collections import deque
8+
from json import JSONDecodeError, load
59
from pathlib import Path
6-
from pprint import pformat
710
from signal import SIGCONT, SIGTERM, SIGTSTP, SIGUSR1, Signals, signal
811
from subprocess import Popen
9-
from syslog import LOG_ERR, LOG_INFO, LOG_WARNING, syslog
1012
from threading import Event, Thread
1113
from traceback import extract_tb, format_stack, format_tb
1214

@@ -21,7 +23,11 @@
2123
from py3status.profiling import profile
2224
from py3status.udev_monitor import UdevMonitor
2325

24-
LOG_LEVELS = {"error": LOG_ERR, "warning": LOG_WARNING, "info": LOG_INFO}
26+
LOGGING_LEVELS = {
27+
"error": logging.ERROR,
28+
"warning": logging.WARNING,
29+
"info": logging.INFO,
30+
}
2531

2632
DBUS_LEVELS = {"error": "critical", "warning": "normal", "info": "low"}
2733

@@ -39,6 +45,8 @@
3945
ENTRY_POINT_NAME = "py3status"
4046
ENTRY_POINT_KEY = "entry_point"
4147

48+
_logger = logging.getLogger("core")
49+
4250

4351
class Runner(Thread):
4452
"""
@@ -521,8 +529,7 @@ def load_modules(self, modules_list, user_modules):
521529
# only handle modules with available methods
522530
if my_m.methods:
523531
self.modules[module] = my_m
524-
elif self.config["debug"]:
525-
self.log(f'ignoring module "{module}" (no methods found)')
532+
_logger.debug('ignoring module "%s" (no methods found)', module)
526533
except Exception:
527534
err = sys.exc_info()[1]
528535
msg = f'Loading module "{module}" failed ({err}).'
@@ -571,10 +578,52 @@ def _log_gitversion(self):
571578
self.log(f"git commit: {commit.hexsha[:7]} {commit.summary}")
572579
self.log(f"git clean: {not repo.is_dirty()!s}")
573580

581+
def _setup_logging(self):
582+
"""Set up the global logger."""
583+
log_config = self.config.get("log_config")
584+
if log_config:
585+
if self.config.get("debug"):
586+
self.report_exception("--debug is invalid when --log-config is passed")
587+
if self.config.get("log_file"):
588+
self.report_exception("--log-file is invalid when --log-config is passed")
589+
590+
with log_config.open() as f:
591+
try:
592+
config_dict = load(f, strict=False)
593+
config_dict.setdefault("disable_existing_loggers", False)
594+
logging.config.dictConfig(config_dict)
595+
except JSONDecodeError as e:
596+
self.report_exception(str(e))
597+
# Nothing else to do. All logging config is provided by the config
598+
# dictionary.
599+
return
600+
601+
root = logging.getLogger(name=None)
602+
if self.config.get("debug"):
603+
root.setLevel(logging.DEBUG)
604+
else:
605+
root.setLevel(logging.INFO)
606+
607+
log_file = self.config.get("log_file")
608+
if log_file:
609+
handler = logging.FileHandler(log_file, encoding="utf8")
610+
else:
611+
# https://stackoverflow.com/a/3969772/340862
612+
handler = logging.handlers.SysLogHandler(address="/dev/log")
613+
handler.setFormatter(
614+
logging.Formatter(
615+
fmt="%(asctime)s %(levelname)s %(module)s %(message)s",
616+
datefmt="%Y-%m-%d %H:%M:%S",
617+
style="%",
618+
)
619+
)
620+
root.addHandler(handler)
621+
574622
def setup(self):
575623
"""
576624
Setup py3status and spawn i3status/events/modules threads.
577625
"""
626+
self._setup_logging()
578627

579628
# log py3status and python versions
580629
self.log("=" * 8)
@@ -586,8 +635,7 @@ def setup(self):
586635

587636
self.log("window manager: {}".format(self.config["wm_name"]))
588637

589-
if self.config["debug"]:
590-
self.log(f"py3status started with config {self.config}")
638+
_logger.debug("py3status started with config %s", self.config)
591639

592640
# read i3status.conf
593641
config_path = self.config["i3status_config_path"]
@@ -637,10 +685,7 @@ def setup(self):
637685
i3s_mode = "mocked"
638686
break
639687
time.sleep(0.1)
640-
if self.config["debug"]:
641-
self.log(
642-
"i3status thread {} with config {}".format(i3s_mode, self.config["py3_config"])
643-
)
688+
_logger.debug("i3status thread %s with config %s", i3s_mode, self.config["py3_config"])
644689

645690
# add i3status thread monitoring task
646691
if i3s_mode == "started":
@@ -651,15 +696,13 @@ def setup(self):
651696
self.events_thread = Events(self)
652697
self.events_thread.daemon = True
653698
self.events_thread.start()
654-
if self.config["debug"]:
655-
self.log("events thread started")
699+
_logger.debug("events thread started")
656700

657701
# initialise the command server
658702
self.commands_thread = CommandServer(self)
659703
self.commands_thread.daemon = True
660704
self.commands_thread.start()
661-
if self.config["debug"]:
662-
self.log("commands thread started")
705+
_logger.debug("commands thread started")
663706

664707
# initialize the udev monitor (lazy)
665708
self.udev_monitor = UdevMonitor(self)
@@ -702,8 +745,7 @@ def setup(self):
702745
# get a dict of all user provided modules
703746
self.log("modules include paths: {}".format(self.config["include_paths"]))
704747
user_modules = self.get_user_configured_modules()
705-
if self.config["debug"]:
706-
self.log(f"user_modules={user_modules}")
748+
_logger.debug("user_modules=%s", user_modules)
707749

708750
if self.py3_modules:
709751
# load and spawn i3status.conf configured modules threads
@@ -813,8 +855,7 @@ def stop(self):
813855

814856
try:
815857
self.lock.set()
816-
if self.config["debug"]:
817-
self.log("lock set, exiting")
858+
_logger.debug("lock set, exiting")
818859
# run kill() method on all py3status modules
819860
for module in self.modules.values():
820861
module.kill()
@@ -845,12 +886,10 @@ def refresh_modules(self, module_string=None, exact=True):
845886
or (not exact and name.startswith(module_string))
846887
):
847888
if module["type"] == "py3status":
848-
if self.config["debug"]:
849-
self.log(f"refresh py3status module {name}")
889+
_logger.debug("refresh py3status module %s", name)
850890
module["module"].force_update()
851891
else:
852-
if self.config["debug"]:
853-
self.log(f"refresh i3status module {name}")
892+
_logger.debug("refresh i3status module %s", name)
854893
update_i3status = True
855894
if update_i3status:
856895
self.i3status_thread.refresh_i3status()
@@ -924,29 +963,11 @@ def notify_update(self, update, urgent=False):
924963
def log(self, msg, level="info"):
925964
"""
926965
log this information to syslog or user provided logfile.
966+
967+
This is soft-deprecated; prefer using the 'logging' module directly in
968+
new code.
927969
"""
928-
if not self.config.get("log_file"):
929-
# If level was given as a str then convert to actual level
930-
level = LOG_LEVELS.get(level, level)
931-
syslog(level, f"{msg}")
932-
else:
933-
# Binary mode so fs encoding setting is not an issue
934-
with self.config["log_file"].open("ab") as f:
935-
log_time = time.strftime("%Y-%m-%d %H:%M:%S")
936-
# nice formatting of data structures using pretty print
937-
if isinstance(msg, (dict, list, set, tuple)):
938-
msg = pformat(msg)
939-
# if multiline then start the data output on a fresh line
940-
# to aid readability.
941-
if "\n" in msg:
942-
msg = "\n" + msg
943-
out = f"{log_time} {level.upper()} {msg}\n"
944-
try:
945-
# Encode unicode strings to bytes
946-
f.write(out.encode("utf-8"))
947-
except (AttributeError, UnicodeDecodeError):
948-
# Write any byte strings straight to log
949-
f.write(out)
970+
_logger.log(LOGGING_LEVELS.get(level, logging.DEBUG), msg)
950971

951972
def create_output_modules(self):
952973
"""

py3status/modules/xrandr.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,13 @@
140140
{'full_text': 'DP1'}
141141
"""
142142

143+
import logging
143144
from collections import OrderedDict, deque
144145
from itertools import combinations
145146
from time import sleep
146147

148+
_logger = logging.getLogger("xrandr")
149+
147150

148151
class Py3status:
149152
""""""
@@ -223,7 +226,7 @@ def _get_layout(self):
223226
else:
224227
continue
225228
except Exception as err:
226-
self.py3.log(f'xrandr error="{err}"')
229+
_logger.exception("xrandr error", err)
227230
else:
228231
layout[state][output] = {"infos": infos, "mode": mode, "state": state}
229232

@@ -307,7 +310,7 @@ def _choose_what_to_display(self, force_refresh=False):
307310
if force_refresh:
308311
self.displayed = self.available_combinations[0]
309312
else:
310-
self.py3.log('xrandr error="displayed combination is not available"')
313+
_logger.warning('xrandr error="displayed combination is not available"')
311314

312315
def _center(self, s):
313316
"""
@@ -343,7 +346,7 @@ def _apply(self, force=False):
343346
resolution = f"--mode {resolution}" if resolution else "--auto"
344347
rotation = getattr(self, f"{output}_rotate", "normal")
345348
if rotation not in ["inverted", "left", "normal", "right"]:
346-
self.py3.log(f"configured rotation {rotation} is not valid")
349+
_logger.warning("configured rotation %s is not valid", rotation)
347350
rotation = "normal"
348351
#
349352
if primary is True and not primary_added:
@@ -365,7 +368,7 @@ def _apply(self, force=False):
365368
self.active_comb = combination
366369
self.active_layout = self.displayed
367370
self.active_mode = mode
368-
self.py3.log(f'command "{cmd}" exit code {code}')
371+
_logger.info('command "%s" exit code %s', cmd, code)
369372

370373
if self.command:
371374
self.py3.command_run(self.command)
@@ -395,7 +398,7 @@ def _apply_workspaces(self, combination, mode):
395398
cmd = '{} move workspace to output "{}"'.format(self.py3.get_wm_msg(), output)
396399
self.py3.command_run(cmd)
397400
# log this
398-
self.py3.log(f"moved workspace {workspace} to output {output}")
401+
_logger.info("moved workspace %s to output %s", workspace, output)
399402

400403
def _fallback_to_available_output(self):
401404
"""
@@ -494,7 +497,7 @@ def xrandr(self):
494497

495498
# follow on change
496499
if not self._no_force_on_change and self.force_on_change and self._layout_changed():
497-
self.py3.log("detected change of monitor setup")
500+
_logger.info("detected change of monitor setup")
498501
self._force_on_change()
499502

500503
# this was a click event triggered update

0 commit comments

Comments
 (0)