''' `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 `.< >` 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