tractor/tests/test_log_sys.py

595 lines
19 KiB
Python

'''
`tractor.log`-wrapping unit tests.
'''
from collections.abc import Callable, Iterator
import importlib
from functools import partial
import logging
from pathlib import Path
import shutil
import sys
from types import ModuleType
import pytest
import tractor
import trio
from tractor import (
_code_load,
log,
)
from tractor.ipc import _chan
def test_root_pkg_not_duplicated_in_logger_name():
'''
When both `pkg_name` and `name` are passed and they have
a common `<root_name>.< >` prefix, ensure that it is not
duplicated in the child's `StackLevelAdapter.name: str`.
Also pins the explicit-`name` contract: an explicitly passed
dotted `name` is treated as a *literal* sub-logger path and is
NOT leaf-collapsed. The leaf-module is only dropped when the
trailing token duplicates the *caller's own* `__name__` leaf (the
`{filename}` field) — see `test_implicit_mod_name_applied_for_child`
for that (auto-naming) path. This is what keeps a real (possibly
nested) sub-PACKAGE like `subpkg.mod` -> `devx.debug` addressable
by the `tractor.log` logging-spec, instead of collapsing to its
parent.
'''
project_name: str = 'pylib'
pkg_path: str = 'pylib.subpkg.mod'
assert not tractor.current_actor(
err_on_no_runtime=False,
)
proj_log = log.get_logger(
pkg_name=project_name,
mk_sublog=False,
)
sublog = log.get_logger(
pkg_name=project_name,
name=pkg_path,
)
assert proj_log is not sublog
# the root pkg-name appears exactly once (no `pylib.pylib...`)
assert sublog.name.count(proj_log.name) == 1
# explicit dotted `name` is preserved literally (NOT collapsed);
# the trailing token survives since it's not the *caller's* own
# leaf-module (`test_log_sys`), so this is treated as a literal
# sub-pkg path.
assert sublog.name == f'{project_name}.subpkg.mod'
def test_implicit_mod_name_applied_for_child(
testdir: pytest.Pytester,
loglevel: str,
):
'''
Verify that when `.log.get_logger(pkg_name='pylib')` is called
from a given sub-mod from within the `pylib` pkg-path, we
implicitly set the equiv of `name=__name__` from the caller's
module.
'''
# tractor.log.get_console_log(level=loglevel)
proj_name: str = 'snakelib'
mod_code: str = (
f'import tractor\n'
f'\n'
# if you need to trace `testdir` stuff @ import-time..
# f'breakpoint()\n'
f'log = tractor.log.get_logger(pkg_name="{proj_name}")\n'
)
# create a sub-module for each pkg layer
_lib = testdir.mkpydir(proj_name)
pkg: Path = Path(_lib)
pkg_init_mod: Path = pkg / "__init__.py"
pkg_init_mod.write_text(mod_code)
subpkg: Path = pkg / 'subpkg'
subpkg.mkdir()
subpkgmod: Path = subpkg / "__init__.py"
subpkgmod.touch()
subpkgmod.write_text(mod_code)
_submod: Path = testdir.makepyfile(
_mod=mod_code,
)
pkg_submod = pkg / 'mod.py'
pkg_subpkg_submod = subpkg / 'submod.py'
shutil.copyfile(
_submod,
pkg_submod,
)
shutil.copyfile(
_submod,
pkg_subpkg_submod,
)
testdir.chdir()
# NOTE, to introspect the py-file-module-layout use (in .xsh
# syntax): `ranger @str(testdir)`
# XXX NOTE, once the "top level" pkg mod has been
# imported, we can then use `import` syntax to
# import it's sub-pkgs and modules.
subpkgmod: ModuleType = _code_load.load_module_from_path(
Path(pkg / '__init__.py'),
module_name=proj_name,
)
pkg_root_log = log.get_logger(
pkg_name=proj_name,
mk_sublog=False,
)
# the top level pkg-mod, created just now,
# by above API call.
assert pkg_root_log.name == proj_name
assert not pkg_root_log.logger.getChildren()
#
# ^TODO! test this same output but created via a `get_logger()`
# call in the `snakelib.__init__py`!!
# NOTE, the pkg-level "init mod" should of course
# have the same name as the package ns-path.
import snakelib as init_mod
assert init_mod.log.name == proj_name
# NOTE, a first-pkg-level sub-module should only
# use the package-name since the leaf-node-module
# will be included in log headers by default.
from snakelib import mod
assert mod.log.name == proj_name
from snakelib import subpkg
assert (
subpkg.log.name
==
subpkg.__package__
==
f'{proj_name}.subpkg'
)
from snakelib.subpkg import submod
assert (
submod.log.name
==
submod.__package__
==
f'{proj_name}.subpkg'
)
sub_logs = pkg_root_log.logger.getChildren()
assert len(sub_logs) == 1 # only one nested sub-pkg module
assert submod.log.logger in sub_logs
def test_implicit_mod_name_from_unregistered_namespace(
tmp_path: Path,
):
'''
Preserve implicit logger naming for dynamic module namespaces.
The fast `sys.modules` caller lookup cannot resolve `runpy`,
plugin-loader, or `exec()` namespaces that are not registered.
Compile a real package file under an unregistered module name so
the rare filename fallback must recover its imported package and
retain the same package-level logger name.
'''
pkg_name = 'dynamic_logger_pkg'
pkg_dir = tmp_path / pkg_name
pkg_dir.mkdir()
init_path = pkg_dir / '__init__.py'
init_path.write_text('')
mod_path = pkg_dir / 'plugin.py'
mod_path.write_text('')
sys.path.insert(0, str(tmp_path))
try:
importlib.import_module(pkg_name)
namespace = {
'__name__': f'{pkg_name}.unregistered',
'__package__': pkg_name,
'tractor': tractor,
}
exec(
compile(
'log = tractor.log.get_logger('
f'pkg_name={pkg_name!r})',
str(mod_path),
'exec',
),
namespace,
)
dynamic_log = namespace.get('log')
finally:
sys.path.remove(str(tmp_path))
sys.modules.pop(pkg_name, None)
assert dynamic_log is not None
assert dynamic_log.name == pkg_name
def test_io_custom_level_registered():
'''
The `IO`(21) level (registered via `add_log_level()` at
import, for `tractor.trionics._subproc`'s std-stream relay)
is fully wired and SHOWN BY DEFAULT at `info`-level consoles
since `21 >= INFO(20)`.
'''
import logging
assert log.CUSTOM_LEVELS.get('IO') == 21
assert logging.getLevelName(21) == 'IO'
assert log.STD_PALETTE.get('IO')
assert log.BOLD_PALETTE['bold'].get('IO')
iolog = log.get_logger('io_lvl_test')
assert callable(getattr(iolog, 'io', None))
# emit must not raise
iolog.io('hello from the IO level')
# 21 >= INFO(20) -> shown when console set to `info`
assert 21 >= logging.INFO
def test_add_log_level_pluggable():
'''
`add_log_level()` is the single pluggable entry-point: one
call wires `CUSTOM_LEVELS` + `addLevelName` + both palettes +
a same-named `StackLevelAdapter` emit method (so
`get_logger()`'s per-level audit passes).
'''
import logging
name: str = 'XLVL'
val: int = 19
try:
log.add_log_level(name, val, 'cyan')
assert log.CUSTOM_LEVELS[name] == val
assert logging.getLevelName(val) == name
assert log.STD_PALETTE[name] == 'cyan'
assert log.BOLD_PALETTE['bold'][name] == 'bold_cyan'
# the audit in `get_logger()` (asserts a method per
# `CUSTOM_LEVELS` entry) must still pass.
xlog = log.get_logger('xlvl_test')
emit = getattr(xlog, name.lower(), None)
assert callable(emit)
emit('hello from a plugged-in level')
finally:
# best-effort cleanup of our module-global mutations so
# later `get_logger()` audits don't see a half-removed
# level.
log.CUSTOM_LEVELS.pop(name, None)
log.STD_PALETTE.pop(name, None)
log.BOLD_PALETTE['bold'].pop(name, None)
if hasattr(log.StackLevelAdapter, name.lower()):
delattr(log.StackLevelAdapter, name.lower())
@pytest.fixture(params=[False, True], ids=['raw', 'adapter'])
def level_guard(
request: pytest.FixtureRequest,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[tuple[logging.Logger, Callable[[int|str], bool]]]:
'''
Return an INFO(20) logger and a log-level enablement query.
`is_info_level_enabled(level)` returns True when the requested
log level meets the logger's current threshold and logging is
not disabled. It queries that decision; it does not emit a msg.
The initial INFO threshold permits INFO(20), CANCEL(22) and
WARNING(30), but blocks DEBUG(10) and NOTSET(0). Tests may change
the logger's threshold or disable flag to query those settings.
Each case allocates a fresh raw `logging.Logger` as `std_log`;
consuming tests do not supply a logger. `request.param` selects
only the returned query:
- False (`raw`): bind `tractor.log.at_least_level` to `std_log`.
- True (`adapter`): wrap that same logger in `StackLevelAdapter`
and return its `at_least_level` method. The raw logger never
supplies an `at_least_level` method.
Register only `std_log` under its name in
`Logger.manager.loggerDict` so `Logger.setLevel()` and
`logging.disable()` can invalidate its enabled-level cache.
`std_log.setLevel(...)` and changes to `std_log.disabled` affect
that logger, not other loggers. The registration is temporary;
`logging.disable()` changes the process-global cutoff. Restore
that cutoff and the registration after each case.
A consumer can use either query form in the same way::
def test_info_query(level_guard):
std_log, is_info_level_enabled = level_guard
assert std_log.level == logging.INFO
assert is_info_level_enabled('INFO')
assert not is_info_level_enabled('DEBUG')
'''
std_log = logging.Logger('level_guard_test', logging.INFO)
monkeypatch.setitem(
logging.Logger.manager.loggerDict,
std_log.name,
std_log,
)
orig_disable: int = logging.root.manager.disable
# Clear inherited global suppression so INFO queries can pass.
# NOTSET(0) removes logging.disable()'s positive-level cutoff.
logging.disable(logging.NOTSET)
try:
is_info_level_enabled: Callable[[int|str], bool]
if request.param:
guarded_log = log.StackLevelAdapter(std_log, {})
is_info_level_enabled = guarded_log.at_least_level
else:
is_info_level_enabled = partial(
log.at_least_level, std_log,
)
yield std_log, is_info_level_enabled
finally:
logging.disable(orig_disable)
@pytest.mark.parametrize(
'level, enabled',
[
# String log levels are compared with the INFO(20) threshold.
('INFO', True),
('info', True),
('WARNING', True),
('warning', True),
('DEBUG', False),
('debug', False),
('ERROR', True),
('CRITICAL', True),
('WARN', True), # WARNING(30) alias, above INFO(20).
('FATAL', True), # CRITICAL(50) alias, above INFO(20).
('CANCEL', True), # Custom CANCEL(22), above INFO(20).
('cancel', True),
('TRANSPORT', False), # TRANSPORT(5), below INFO(20).
(logging.INFO, True), # 20 equals the INFO(20) threshold.
(logging.DEBUG, False), # 10 is below INFO(20).
(22, True), # CANCEL(22) is above INFO(20).
(0, False), # NOTSET(0) is below INFO(20).
('NOTSET', False),
('notset', False),
],
)
def test_at_least_level_names_and_integers(
level_guard,
level: int|str,
enabled: bool,
):
'''
String names of built-in log levels must not raise `KeyError`.
`tractor.log.at_least_level()` indexed `CUSTOM_LEVELS` for every
string query, so built-in logging levels such as 'INFO' raised
`KeyError` before testing whether logging was enabled. Query the
documented log levels and aliases, lowercase spellings, custom
levels and integer equivalents. Each result must match its
expected INFO(20) threshold comparison without changing that
threshold; NOTSET must resolve even though its value is zero.
See Python's `logging` docs for built-in level values:
https://docs.python.org/3/library/logging.html#logging-levels
'''
std_log, is_info_level_enabled = level_guard
assert is_info_level_enabled(level) is enabled
# A query must not reconfigure the INFO(20) logger.
assert std_log.level == logging.INFO
def test_at_least_level_registered_name(
level_guard,
monkeypatch: pytest.MonkeyPatch,
):
'''
Query a custom log level registered with `logging.addLevelName()`.
`tractor.log.at_least_level()` raised `KeyError` when a string
log level was absent from `CUSTOM_LEVELS`, even when registered
by `logging.addLevelName()`. Register APP_LEVEL_GUARD(35), between
WARNING(30) and ERROR(40). Its lowercase query must pass at
INFO(20), and its uppercase query must fail at ERROR(40). Restore
the logging registrations afterward so later tests do not inherit
this custom level.
'''
monkeypatch.setattr(
logging, '_nameToLevel', logging._nameToLevel.copy(),
)
monkeypatch.setattr(
logging, '_levelToName', logging._levelToName.copy(),
)
# Custom 35 is above INFO(20), but below ERROR(40).
logging.addLevelName(35, 'APP_LEVEL_GUARD')
std_log, is_level_enabled = level_guard
assert is_level_enabled('app_level_guard') is True
std_log.setLevel(logging.ERROR)
assert is_level_enabled('APP_LEVEL_GUARD') is False
@pytest.mark.parametrize('level', ['unknown_level_guard', '20', ''])
def test_at_least_level_unknown_name(
level_guard,
level: str,
):
'''
Reject unknown log-level names with an explicit `KeyError`.
The old `CUSTOM_LEVELS` lookup raised `KeyError` with the
uppercase query as its key. Log-level name lookup must not
silently enable misspelled levels or treat numeric strings as
integer inputs. Each invalid name must still raise `KeyError`
carrying its uppercase spelling, including an empty name.
'''
std_log, is_info_level_enabled = level_guard
with pytest.raises(KeyError) as excinfo:
is_info_level_enabled(level)
assert excinfo.value.args == (level.upper(),)
assert std_log.level == logging.INFO
@pytest.mark.parametrize('level', ['info', 'cancel', logging.INFO, 22])
@pytest.mark.parametrize('suppression', ['logger', 'global'])
def test_at_least_level_disable_controls(
level_guard,
level: int|str,
suppression: str,
):
'''
Log-level name resolution must honor logging disable controls.
Earlier guards checked only `Logger.getEffectiveLevel()` and
rendered payloads despite disable controls. Prime a positive
enabled-level result, then disable the logger or all logging.
Built-in INFO and custom CANCEL string names, plus their integer
equivalents, must return False even though the logger's effective
INFO(20) threshold remains unchanged. Query once before disabling
to ensure a cached True result cannot bypass suppression.
'''
std_log, is_info_level_enabled = level_guard
assert is_info_level_enabled(level) is True
if suppression == 'logger':
std_log.disabled = True
else:
logging.disable(logging.CRITICAL)
assert is_info_level_enabled(level) is False
assert std_log.getEffectiveLevel() == logging.INFO
@pytest.mark.parametrize(
'suppression',
[
'level',
'logger',
'global',
],
)
def test_log_guard_skips_payload_formatting(
monkeypatch: pytest.MonkeyPatch,
suppression: str,
):
'''
Suppressed transport logs must not render payloads.
The original hot-path guard compared only the effective logger
level. A logger disabled through its `Logger.disabled` flag or
the global `logging.disable()` threshold could therefore still
call `pformat()` before `Logger.isEnabledFor()` discarded the
record.
Exercise effective-level, per-logger, and global suppression
independently. A poisoned `_chan.pformat()` proves rendering is
skipped, while the fake transport proves `Channel.send()` still
transmits the original payload and traceback-hiding flag.
'''
sent: list[tuple[object, bool]] = []
class FakeTransport:
async def send(
self,
payload: object,
hide_tb: bool = False,
) -> None:
sent.append((payload, hide_tb))
def fail_pformat(payload: object) -> str:
raise AssertionError(
f'suppressed log rendered payload: {payload!r}'
)
chan_log = log.get_logger(
name=f'guard_test.{suppression}',
)
std_log = chan_log.logger
orig_level: int = std_log.level
orig_disable: int = logging.root.manager.disable
transport_level: int = log.CUSTOM_LEVELS['TRANSPORT']
monkeypatch.setattr(_chan, 'log', chan_log)
monkeypatch.setattr(_chan, 'pformat', fail_pformat)
try:
logging.disable(logging.NOTSET)
std_log.setLevel(transport_level)
if suppression == 'level':
std_log.setLevel(logging.INFO)
elif suppression == 'logger':
monkeypatch.setattr(std_log, 'disabled', True)
else:
logging.disable(logging.CRITICAL)
assert not chan_log.isEnabledFor(transport_level)
transport = FakeTransport()
chan = _chan.Channel(transport=transport)
payload = object()
async def send_payload() -> None:
await chan.send(
payload,
hide_tb=True,
)
trio.run(send_payload)
assert sent == [(payload, True)]
finally:
std_log.setLevel(orig_level)
logging.disable(orig_disable)
# TODO, moar tests against existing feats:
# ------ - ------
# - [ ] color settings?
# - [ ] header contents like,
# - actor + thread + task names from various conc-primitives,
# - [ ] `StackLevelAdapter` extensions,
# - our custom levels/methods: `transport|runtime|cance|pdb|devx`
# - [ ] custom-headers support?
#
# TODO, test driven dev of new-ideas/long-wanted feats,
# ------ - ------
# - [ ] https://github.com/goodboy/tractor/issues/244
# - [ ] @catern mentioned using a sync / deterministic sys
# and in particular `svlogd`?
# |_ https://smarden.org/runit/svlogd.8
# - [ ] using adapter vs. filters?
# - https://stackoverflow.com/questions/60691759/add-information-to-every-log-message-in-python-logging/61830838#61830838
# - [ ] `.at_least_level()` optimization which short circuits wtv
# `logging` is doing behind the scenes when the level filters
# the emission..?
# - [ ] use of `.log.get_console_log()` in subactors and the
# subtleties of ensuring it actually emits from a subproc.
# - [ ] this idea of activating per-subsys emissions with some
# kind of `.name` filter passed to the runtime or maybe configured
# via the root `StackLevelAdapter`?
# - [ ] use of `logging.dict.dictConfig()` to simplify the impl
# of any of ^^ ??
# - https://stackoverflow.com/questions/7507825/where-is-a-complete-example-of-logging-config-dictconfig
# - https://docs.python.org/3/library/logging.config.html#configuration-dictionary-schema
# - https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig