Skip to content

Commit f777b84

Browse files
Merge pull request #2 from fleetingbytes/develop
Develop
2 parents 5261140 + 99359ee commit f777b84

File tree

14 files changed

+447
-41
lines changed

14 files changed

+447
-41
lines changed

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.14
1+
3.12

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ lint = [
3030
]
3131
test = [
3232
"pytest>=8.4.2",
33+
"watchdog>=6.0.0",
3334
]
3435

3536
[project.urls]

ruff.toml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
line-length = 108
2+
3+
[format]
4+
line-ending = "lf"
5+
docstring-code-format = true
6+
7+
[lint]
8+
preview = true
9+
select = [
10+
"B", # flake8-bugbear
11+
"C901", # mcabe
12+
"E", # pycodestyle errors
13+
"ERA", # eradicate (commented-out-code)
14+
"F", # pyflakes
15+
"FLY", # flynt
16+
"G", # flake8-logging-format
17+
"I", # isort
18+
"ICN", # flake8-import-conventions
19+
"INT", # flake8-gettext
20+
"ISC", # flake8-implicit-str-concat
21+
"LOG", # flake8-logging
22+
"N", # pep8-naming
23+
"PERF", # Perflint
24+
"PL", # pylint
25+
"Q", # quotes
26+
"RUF", # ruff-specific rules
27+
"SIM", # flake8-simplify
28+
"TC", # flake8-type-checking
29+
"TID251", # banned-api
30+
"TID253", # banned-module-level-imports
31+
"UP", # pyupgrade
32+
"W", # pycodestyle warnings
33+
]
34+
35+
[lint.per-file-ignores]
36+
"src/**/cli/__init__.py" = [
37+
"N813", # camelcase-imported-as-lowercase
38+
]
39+
"tests/test_general.py" = [
40+
"N813", # camelcase-imported-as-lowercase
41+
]
42+
"tests/test_log.py" = [
43+
"N813", # camelcase-imported-as-lowercase
44+
]
45+
"tests/test_decorators.py" = [
46+
"N813", # camelcase-imported-as-lowercase
47+
"PLR917", # too-many-positional-arguments
48+
]
49+
"src/readylog/__init__.py" = [
50+
"PLR0913", # too-many-arguments
51+
"PLR0914", # too-many-local-variables
52+
"PLR0917", # too-many-positional-arguments
53+
"PLC2701", # import-private-name
54+
]
55+
56+
extend-safe-fixes = [
57+
"TC001", # typing-only-first-party-import
58+
"TC002", # typing-only-third-party-import
59+
"TC003", # typing-only-standard-library-import
60+
]
61+
62+
[lint.mccabe]
63+
max-complexity = 5

src/readylog/__init__.py

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,21 @@
1-
"""
2-
Copy this file into your project
3-
to manage your logging configuration there.
4-
Don't forget to change the name of your app and the names of your loggers here!
5-
6-
Use it like this:
7-
8-
from logging import getLogger
9-
from logging.config import dictConfig as configure_logging
10-
11-
from readylog import logging_configuration
12-
13-
14-
configure_logging(logging_configuration)
15-
16-
logger = getLogger(__name__)
17-
"""
18-
1+
from collections.abc import Callable
2+
from logging import FileHandler, StreamHandler, _checkLevel, getLevelName
193
from pathlib import Path
4+
from sys import modules
205

21-
from platformdirs import user_log_dir
226

23-
MY_APP_NAME = "MY_APP_NAME".replace("-", "_")
7+
def create_dict_config(
8+
logfile: Path,
9+
app_name: str,
10+
console_log_level: str | int = "WARNING",
11+
file_log_level: str | int = "DEBUG",
12+
console_handler_factory: Callable = StreamHandler,
13+
file_handler_factory: Callable = FileHandler,
14+
) -> dict[str, str]:
15+
console_log_level = _checkLevel(console_log_level)
16+
file_log_level = _checkLevel(file_log_level)
17+
min_level = getLevelName(min(console_log_level, file_log_level))
2418

25-
logging_dir = Path(user_log_dir(MY_APP_NAME))
26-
logging_dir.mkdir(parents=True, exist_ok=True)
27-
logfile = logging_dir / "debug.log"
28-
29-
30-
def create_dict_config(logfile: Path) -> dict[str, str]:
3119
custom_file_formatter_conf = {
3220
"format": "{message:<50s} {levelname:>9s} {asctime}.{msecs:03.0f} {module}({lineno}) {funcName}",
3321
"style": "{",
@@ -60,30 +48,30 @@ def create_dict_config(logfile: Path) -> dict[str, str]:
6048
}
6149

6250
custom_console_handler_conf = {
63-
"class": "logging.StreamHandler",
64-
"level": "DEBUG",
51+
"()": console_handler_factory,
52+
"level": console_log_level,
6553
"formatter": "custom_console_formatter",
6654
"stream": "ext://sys.stderr",
6755
}
6856

6957
custom_file_handler_conf = {
70-
"class": "logging.FileHandler",
71-
"level": "DEBUG",
58+
"()": file_handler_factory,
59+
"level": file_log_level,
7260
"formatter": "custom_file_formatter",
7361
"filename": logfile,
7462
"mode": "w",
7563
"encoding": "utf-8",
7664
}
7765

7866
root_console_handler_conf = {
79-
"class": "logging.StreamHandler",
67+
"()": console_handler_factory,
8068
"level": "DEBUG",
8169
"formatter": "root_console_formatter",
8270
"stream": "ext://sys.stderr",
8371
}
8472

8573
root_file_handler_conf = {
86-
"class": "logging.FileHandler",
74+
"()": file_handler_factory,
8775
"level": "DEBUG",
8876
"formatter": "root_file_formatter",
8977
"filename": logfile.with_stem(f"{logfile.stem}_root"),
@@ -101,7 +89,7 @@ def create_dict_config(logfile: Path) -> dict[str, str]:
10189
custom_logger_conf = {
10290
"propagate": False,
10391
"handlers": ["custom_file_handler", "custom_console_handler"],
104-
"level": "DEBUG",
92+
"level": min_level,
10593
}
10694

10795
root_logger_conf = {
@@ -110,8 +98,9 @@ def create_dict_config(logfile: Path) -> dict[str, str]:
11098
}
11199

112100
loggers_dict = {
113-
MY_APP_NAME: custom_logger_conf,
101+
app_name: custom_logger_conf,
114102
"__main__": custom_logger_conf,
103+
f"{modules[__name__].__spec__.parent}.decorators": custom_logger_conf,
115104
}
116105

117106
dict_config = {
@@ -125,6 +114,3 @@ def create_dict_config(logfile: Path) -> dict[str, str]:
125114
}
126115

127116
return dict_config
128-
129-
130-
logging_configuration = create_dict_config(logfile)

src/readylog/decorators.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from functools import wraps
2+
from itertools import product
3+
from logging import DEBUG, getLevelName, getLevelNamesMapping, getLogger
4+
from sys import modules
5+
6+
logger = getLogger(__name__)
7+
8+
9+
def log_io(level=DEBUG, enter=False, exit=False):
10+
"""
11+
Decorator factory that logs function input arguments and return values
12+
at the specified logging level.
13+
Usage:
14+
from logging import DEBUG, INFO
15+
16+
debug = @log_io(DEBUG)
17+
info = @log_io(INFO)
18+
19+
20+
@debug
21+
def my_func(...):
22+
...
23+
24+
25+
@info
26+
def more_important_func(...):
27+
...
28+
"""
29+
30+
def decorator(func):
31+
@wraps(func)
32+
def wrapper(*args, **kwargs):
33+
if enter:
34+
logger.log(
35+
level,
36+
"Calling %s with args=%s, kwargs=%s",
37+
func.__name__,
38+
args,
39+
kwargs,
40+
stacklevel=2,
41+
)
42+
result = func(*args, **kwargs)
43+
if exit:
44+
logger.log(level, "%s returned %r", func.__name__, result, stacklevel=2)
45+
return result
46+
47+
return wrapper
48+
49+
return decorator
50+
51+
52+
level_names = getLevelNamesMapping().keys()
53+
54+
for level, direction in product(level_names, ("_in", "_out", "")):
55+
if direction == "_in":
56+
enter = True
57+
exit = False
58+
elif direction == "_out":
59+
enter = False
60+
exit = True
61+
else:
62+
enter = True
63+
exit = True
64+
setattr(
65+
modules[__name__],
66+
f"{level.lower()}{direction}",
67+
log_io(getLevelName(level), enter=enter, exit=exit),
68+
)

src/readylog/mixins.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from copy import copy
2+
from logging import FileHandler, StreamHandler
3+
4+
5+
class MultilineMixin:
6+
def emit(self, record):
7+
s = record.getMessage()
8+
if "\n" not in s:
9+
super().emit(record)
10+
else:
11+
lines = s.splitlines()
12+
rec = copy(record)
13+
rec.args = None
14+
for line in lines:
15+
rec.msg = line
16+
super().emit(rec)
17+
18+
19+
class MultilineStreamHandler(MultilineMixin, StreamHandler):
20+
pass
21+
22+
23+
class MultilineFileHandler(MultilineMixin, FileHandler):
24+
pass

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from collections.abc import Callable
2+
from pathlib import Path
3+
from typing import Any
4+
5+
from pytest import FixtureRequest, fixture
6+
from watchdog.events import FileModifiedEvent, FileSystemEventHandler
7+
from watchdog.observers import Observer
8+
9+
10+
@fixture
11+
def tmp_log_file(tmp_path) -> Path:
12+
path = tmp_path / "log" / "debug.log"
13+
path.parent.mkdir(parents=True, exist_ok=False)
14+
return path
15+
16+
17+
@fixture
18+
def app_name() -> str:
19+
return "test_app"
20+
21+
22+
@fixture
23+
def log_file_observer(tmp_log_file, request: FixtureRequest) -> Observer:
24+
expected_number_of_log_lines = request.param
25+
26+
class LogFileChangeHandler(FileSystemEventHandler):
27+
@staticmethod
28+
def on_modified(event: FileModifiedEvent):
29+
if event.src_path.endswith(tmp_log_file.name):
30+
pass
31+
32+
observer = Observer()
33+
handler = LogFileChangeHandler()
34+
tmp_log_file.touch()
35+
observer.schedule(handler, path=tmp_log_file, recursive=False)
36+
observer.start()
37+
yield observer
38+
39+
with open(tmp_log_file, encoding="utf-8") as f:
40+
lines = f.readlines()
41+
number_of_log_lines = len(lines)
42+
assert number_of_log_lines == expected_number_of_log_lines
43+
44+
observer.stop()
45+
observer.join()
46+
47+
48+
def function_under_test(*args, **kwargs) -> tuple[tuple[Any, ...], dict[Any, Any]]:
49+
return args, kwargs
50+
51+
52+
@fixture
53+
def decorated_function(request: FixtureRequest) -> Callable:
54+
decorator = request.param
55+
decorated_func = decorator(function_under_test)
56+
return decorated_func

tests/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from logging import getLevelNamesMapping
2+
3+
IGNORED_LEVEL = "NOTSET"
4+
5+
USABLE_LEVELS = {k: v for k, v in getLevelNamesMapping().items() if k != IGNORED_LEVEL}
6+
USABLE_LEVEL_NAMES = tuple(key for key in USABLE_LEVELS)

0 commit comments

Comments
 (0)