├── emptylog ├── py.typed ├── empty_logger.py ├── call_data.py ├── __init__.py ├── accumulated_data.py ├── protocols.py ├── memory_logger.py ├── abstract_logger.py ├── printing_logger.py └── loggers_group.py ├── tests ├── __init__.py ├── test_call_data.py ├── test_accumulated_data.py ├── test_empty_logger.py ├── test_protocols.py ├── test_memory_logger.py ├── test_abstract_logger.py ├── test_printing_logger.py └── test_loggers_group.py ├── .ruff.toml ├── docs └── assets │ ├── logo_6.png │ ├── logo_1.svg │ ├── logo_2.svg │ ├── logo_4.svg │ ├── logo_3.svg │ └── logo_5.svg ├── requirements_dev.txt ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ ├── documentation.md │ └── bug_report.md └── workflows │ ├── release.yml │ ├── lint.yml │ └── tests_and_coverage.yml ├── LICENSE ├── pyproject.toml └── README.md /emptylog/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | ignore = ['E501', 'E712'] 2 | -------------------------------------------------------------------------------- /docs/assets/logo_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pomponchik/emptylog/HEAD/docs/assets/logo_6.png -------------------------------------------------------------------------------- /emptylog/empty_logger.py: -------------------------------------------------------------------------------- 1 | from emptylog.abstract_logger import AbstractLogger 2 | 3 | 4 | class EmptyLogger(AbstractLogger): 5 | pass 6 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest==8.0.2 2 | coverage==7.6.1 3 | build==1.2.2.post1 4 | twine==6.1.0 5 | mypy==1.14.1 6 | ruff==0.14.6 7 | mutmut==3.2.3 8 | full_match==0.0.3 9 | loguru==0.7.3 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | venv 4 | .pytest_cache 5 | build 6 | dist 7 | *.egg-info 8 | test.py 9 | .coverage 10 | .coverage.* 11 | .idea 12 | .ruff_cache 13 | .mutmut-cache 14 | .mypy_cache 15 | -------------------------------------------------------------------------------- /emptylog/call_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Tuple, Dict, Any 3 | 4 | 5 | @dataclass 6 | class LoggerCallData: 7 | message: str 8 | args: Tuple[Any, ...] 9 | kwargs: Dict[str, Any] 10 | -------------------------------------------------------------------------------- /tests/test_call_data.py: -------------------------------------------------------------------------------- 1 | from emptylog.call_data import LoggerCallData 2 | 3 | 4 | def test_create_call_data_and_check_the_data(): 5 | data = LoggerCallData('kek', (), {}) 6 | 7 | assert data.message == 'kek' 8 | assert data.args == () 9 | assert data.kwargs == {} 10 | -------------------------------------------------------------------------------- /emptylog/__init__.py: -------------------------------------------------------------------------------- 1 | from emptylog.empty_logger import EmptyLogger as EmptyLogger # noqa: F401 2 | from emptylog.protocols import LoggerProtocol as LoggerProtocol # noqa: F401 3 | from emptylog.memory_logger import MemoryLogger as MemoryLogger # noqa: F401 4 | from emptylog.printing_logger import PrintingLogger as PrintingLogger # noqa: F401 5 | from emptylog.loggers_group import LoggersGroup as LoggersGroup # noqa: F401 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question or consultation 3 | about: Ask anything about this project 4 | title: '' 5 | labels: guestion 6 | assignees: pomponchik 7 | 8 | --- 9 | 10 | ## Your question 11 | 12 | Here you can freely describe your question about the project. Please, before doing this, read the documentation provided, and ask the question only if the necessary answer is not there. In addition, please keep in mind that this is a free non-commercial project and user support is optional for its author. The response time is not guaranteed in any way. 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: pomponchik 7 | 8 | --- 9 | 10 | ## Short description 11 | 12 | What do you propose and why do you consider it important? 13 | 14 | 15 | ## Some details 16 | 17 | If you can, provide code examples that will show how your proposal will work. Also, if you can, indicate which alternatives to this behavior you have considered. And finally, how do you propose to test the correctness of the implementation of your idea, if at all possible? 18 | -------------------------------------------------------------------------------- /emptylog/accumulated_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | 4 | from emptylog.call_data import LoggerCallData 5 | 6 | 7 | @dataclass 8 | class LoggerAccumulatedData: 9 | debug: List[LoggerCallData] = field(default_factory=list) 10 | info: List[LoggerCallData] = field(default_factory=list) 11 | warning: List[LoggerCallData] = field(default_factory=list) 12 | error: List[LoggerCallData] = field(default_factory=list) 13 | exception: List[LoggerCallData] = field(default_factory=list) 14 | critical: List[LoggerCallData] = field(default_factory=list) 15 | 16 | def __len__(self) -> int: 17 | return len(self.debug) + len(self.info) + len(self.warning) + len(self.error) + len(self.exception) + len(self.critical) 18 | -------------------------------------------------------------------------------- /tests/test_accumulated_data.py: -------------------------------------------------------------------------------- 1 | from emptylog.call_data import LoggerCallData 2 | from emptylog.accumulated_data import LoggerAccumulatedData 3 | 4 | 5 | def test_fill_accumulated_data_and_check_size(): 6 | data = LoggerAccumulatedData() 7 | 8 | lists = [ 9 | data.debug, 10 | data.info, 11 | data.warning, 12 | data.error, 13 | data.exception, 14 | data.critical, 15 | ] 16 | 17 | logs_sum = 0 18 | 19 | for index, logs_list in enumerate(lists): 20 | assert len(data) == logs_sum 21 | 22 | for _ in range(index + 1): 23 | logs_list.append(LoggerCallData('some message', (), {})) 24 | logs_sum += 1 25 | 26 | assert len(data) == logs_sum 27 | 28 | assert len(data) == 21 29 | -------------------------------------------------------------------------------- /emptylog/protocols.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, runtime_checkable, Any 2 | 3 | 4 | @runtime_checkable 5 | class LoggerProtocol(Protocol): 6 | def debug(self, message: str, *args: Any, **kwargs: Any) -> None: return None 7 | def info(self, message: str, *args: Any, **kwargs: Any) -> None: return None 8 | def warning(self, message: str, *args: Any, **kwargs: Any) -> None: return None 9 | def error(self, message: str, *args: Any, **kwargs: Any) -> None: return None 10 | def exception(self, message: str, *args: Any, **kwargs: Any) -> None: return None 11 | def critical(self, message: str, *args: Any, **kwargs: Any) -> None: return None 12 | 13 | 14 | class LoggerMethodProtocol(Protocol): 15 | def __call__(self, message: str, *args: Any, **kwargs: Any) -> None: return None # pragma: no cover 16 | -------------------------------------------------------------------------------- /tests/test_empty_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from emptylog import EmptyLogger 4 | 5 | 6 | def test_search_callable_attributes(): 7 | attribute_names = [ 8 | 'debug', 9 | 'info', 10 | 'warning', 11 | 'error', 12 | 'exception', 13 | 'critical', 14 | ] 15 | 16 | empty_logger = EmptyLogger() 17 | real_logger = logging.getLogger('kek') 18 | 19 | for name in attribute_names: 20 | for logger in [empty_logger, real_logger]: 21 | method = getattr(logger, name) 22 | 23 | assert callable(method) 24 | 25 | method('kek') 26 | method('kek %s', 'lol') 27 | method('kek %s', 'lol', extra={'lol': 'kek'}) 28 | 29 | 30 | def test_repr_empty_logger(): 31 | assert repr(EmptyLogger()) == 'EmptyLogger()' 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation fix 3 | about: Add something to the documentation, delete it, or change it 4 | title: '' 5 | labels: documentation 6 | assignees: pomponchik 7 | --- 8 | 9 | ## It's cool that you're here! 10 | 11 | Documentation is an important part of the project, we strive to make it high-quality and keep it up to date. Please adjust this template by outlining your proposal. 12 | 13 | 14 | ## Type of action 15 | 16 | What do you want to do: remove something, add it, or change it? 17 | 18 | 19 | ## Where? 20 | 21 | Specify which part of the documentation you want to make a change to? For example, the name of an existing documentation section or the line number in a file `README.md`. 22 | 23 | 24 | ## The essence 25 | 26 | Please describe the essence of the proposed change 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: pomponchik 7 | 8 | --- 9 | 10 | ## Short description 11 | 12 | Replace this text with a short description of the error and the behavior that you expected to see instead. 13 | 14 | 15 | ## Describe the bug in detail 16 | 17 | Please add this test in such a way that it reproduces the bug you found and does not pass: 18 | 19 | ```python 20 | def test_your_bug(): 21 | ... 22 | ``` 23 | 24 | Writing the test, please keep compatibility with the [`pytest`](https://docs.pytest.org/) framework. 25 | 26 | If for some reason you cannot describe the error in the test format, describe here the steps to reproduce it. 27 | 28 | 29 | ## Environment 30 | - OS: ... 31 | - Python version (the output of the `python --version` command): ... 32 | - Version of this package: ... 33 | -------------------------------------------------------------------------------- /tests/test_protocols.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from loguru import logger as loguru_logger 4 | 5 | from emptylog import LoggerProtocol, EmptyLogger, LoggersGroup, MemoryLogger, PrintingLogger 6 | 7 | 8 | def test_positive_examples_of_runtime_check(): 9 | assert isinstance(logging, LoggerProtocol) 10 | assert isinstance(logging.getLogger('kek'), LoggerProtocol) 11 | assert isinstance(logging.getLogger('lol'), LoggerProtocol) 12 | assert isinstance(EmptyLogger(), LoggerProtocol) 13 | assert isinstance(LoggersGroup(), LoggerProtocol) 14 | assert isinstance(MemoryLogger(), LoggerProtocol) 15 | assert isinstance(PrintingLogger(), LoggerProtocol) 16 | 17 | 18 | def test_negative_examples_of_runtime_check(): 19 | assert not isinstance(1, LoggerProtocol) 20 | assert not isinstance('logging', LoggerProtocol) 21 | 22 | 23 | def test_loguru_logger_is_logger(): 24 | assert isinstance(loguru_logger, LoggerProtocol) 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pypi-publish: 10 | name: upload release to PyPI 11 | runs-on: ubuntu-latest 12 | # Specifying a GitHub environment is optional, but strongly encouraged 13 | environment: release 14 | permissions: 15 | # IMPORTANT: this permission is mandatory for trusted publishing 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | shell: bash 27 | run: pip install -r requirements_dev.txt 28 | 29 | - name: Build the project 30 | shell: bash 31 | run: python -m build . 32 | 33 | - name: Publish package distributions to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install dependencies 23 | shell: bash 24 | run: pip install -r requirements_dev.txt 25 | 26 | - name: Install the library 27 | shell: bash 28 | run: pip install . 29 | 30 | - name: Run mypy 31 | shell: bash 32 | run: mypy emptylog --strict 33 | 34 | - name: Run mypy for tests 35 | shell: bash 36 | run: mypy tests 37 | 38 | - name: Run ruff 39 | shell: bash 40 | run: ruff check emptylog 41 | 42 | - name: Run ruff for tests 43 | shell: bash 44 | run: ruff check tests 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 pomponchik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /emptylog/memory_logger.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from emptylog.abstract_logger import AbstractLogger 4 | from emptylog.call_data import LoggerCallData 5 | from emptylog.accumulated_data import LoggerAccumulatedData 6 | 7 | 8 | class MemoryLogger(AbstractLogger): 9 | def __init__(self) -> None: 10 | self.data = LoggerAccumulatedData() 11 | 12 | def debug(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.debug.append(LoggerCallData(message, args, kwargs)) 13 | def info(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.info.append(LoggerCallData(message, args, kwargs)) 14 | def warning(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.warning.append(LoggerCallData(message, args, kwargs)) 15 | def error(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.error.append(LoggerCallData(message, args, kwargs)) 16 | def exception(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.exception.append(LoggerCallData(message, args, kwargs)) 17 | def critical(self, message: str, *args: Any, **kwargs: Any) -> None: self.data.critical.append(LoggerCallData(message, args, kwargs)) 18 | -------------------------------------------------------------------------------- /emptylog/abstract_logger.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from emptylog.protocols import LoggerProtocol 4 | 5 | 6 | class AbstractLogger(LoggerProtocol, ABC): 7 | def __repr__(self) -> str: 8 | return f'{type(self).__name__}()' 9 | 10 | def __add__(self, other: LoggerProtocol) -> 'LoggersGroup': # type: ignore[name-defined] # noqa: F821 11 | if not isinstance(other, LoggerProtocol): 12 | raise NotImplementedError('The addition operation is defined only for loggers.') 13 | 14 | from emptylog import LoggersGroup 15 | 16 | local_loggers = self.loggers if isinstance(self, LoggersGroup) else [self] 17 | other_loggers = other.loggers if isinstance(other, LoggersGroup) else [other] 18 | 19 | return LoggersGroup(*local_loggers, *other_loggers) 20 | 21 | def __radd__(self, other: LoggerProtocol) -> 'LoggersGroup': # type: ignore[name-defined] # noqa: F821 22 | if not isinstance(other, LoggerProtocol): 23 | raise NotImplementedError('The addition operation is defined only for loggers.') 24 | 25 | from emptylog import LoggersGroup 26 | 27 | local_loggers = self.loggers if isinstance(self, LoggersGroup) else [self] 28 | other_loggers = other.loggers if isinstance(other, LoggersGroup) else [other] 29 | 30 | return LoggersGroup(*other_loggers, *local_loggers) 31 | -------------------------------------------------------------------------------- /emptylog/printing_logger.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Any 2 | from datetime import datetime 3 | from functools import partial 4 | 5 | from emptylog.abstract_logger import AbstractLogger 6 | 7 | 8 | class PrintingLogger(AbstractLogger): 9 | def __init__(self, printing_callback: Callable[[Any], Any] = partial(print, end=''), separator: str = '|') -> None: 10 | self.callback = printing_callback 11 | self.separator = separator 12 | self.template = '{time} {separator} {level} {separator} {message}\n' 13 | 14 | def debug(self, message: str, *args: Any, **kwargs: Any) -> None: 15 | self.callback(self.create_line('DEBUG', message)) 16 | 17 | def info(self, message: str, *args: Any, **kwargs: Any) -> None: 18 | self.callback(self.create_line('INFO', message)) 19 | 20 | def warning(self, message: str, *args: Any, **kwargs: Any) -> None: 21 | self.callback(self.create_line('WARNING', message)) 22 | 23 | def error(self, message: str, *args: Any, **kwargs: Any) -> None: 24 | self.callback(self.create_line('ERROR', message)) 25 | 26 | def exception(self, message: str, *args: Any, **kwargs: Any) -> None: 27 | self.callback(self.create_line('EXCEPTION', message)) 28 | 29 | def critical(self, message: str, *args: Any, **kwargs: Any) -> None: 30 | self.callback(self.create_line('CRITICAL', message)) 31 | 32 | def create_line(self, level: str, message: str) -> str: 33 | return self.template.format(time=datetime.now(), level=level.ljust(9), message=message, separator=self.separator) 34 | -------------------------------------------------------------------------------- /.github/workflows/tests_and_coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-latest, ubuntu-latest, windows-latest] 13 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t'] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install the library 23 | shell: bash 24 | run: pip install . 25 | 26 | - name: Install dependencies 27 | shell: bash 28 | run: pip install -r requirements_dev.txt 29 | 30 | - name: Print all libs 31 | shell: bash 32 | run: pip list 33 | 34 | - name: Run tests and show coverage on the command line 35 | run: | 36 | coverage run --source=emptylog --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100 37 | coverage xml 38 | 39 | - name: Upload coverage to Coveralls 40 | if: runner.os == 'Linux' 41 | env: 42 | COVERALLS_REPO_TOKEN: ${{secrets.COVERALLS_REPO_TOKEN}} 43 | uses: coverallsapp/github-action@v2 44 | with: 45 | format: cobertura 46 | file: coverage.xml 47 | 48 | - name: Run tests and show the branch coverage on the command line 49 | run: coverage run --branch --source=emptylog --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools==68.0.0'] 3 | build-backend = 'setuptools.build_meta' 4 | 5 | [project] 6 | name = 'emptylog' 7 | version = '0.0.11' 8 | authors = [ 9 | { name='Evgeniy Blinov', email='zheni-b@yandex.ru' }, 10 | ] 11 | description = 'Mimicking the logger protocol' 12 | readme = 'README.md' 13 | requires-python = '>=3.8' 14 | dependencies = [ 15 | 'printo>=0.0.8', 16 | ] 17 | classifiers = [ 18 | 'Operating System :: MacOS :: MacOS X', 19 | 'Operating System :: Microsoft :: Windows', 20 | 'Operating System :: POSIX', 21 | 'Programming Language :: Python :: 3.8', 22 | 'Programming Language :: Python :: 3.9', 23 | 'Programming Language :: Python :: 3.10', 24 | 'Programming Language :: Python :: 3.11', 25 | 'Programming Language :: Python :: 3.12', 26 | 'Programming Language :: Python :: 3.13', 27 | 'Programming Language :: Python :: 3.14', 28 | 'License :: OSI Approved :: MIT License', 29 | 'Topic :: Software Development :: Libraries', 30 | 'Topic :: Software Development :: Testing', 31 | 'Topic :: Software Development :: Testing :: Mocking', 32 | 'Topic :: Software Development :: Testing :: Unit', 33 | 'Topic :: System :: Logging', 34 | 'Intended Audience :: Developers', 35 | 'Typing :: Typed', 36 | ] 37 | keywords = [ 38 | 'logging', 39 | 'protocols', 40 | 'loggers mocks', 41 | ] 42 | 43 | [tool.setuptools.package-data] 44 | "emptylog" = ["py.typed"] 45 | 46 | [tool.mutmut] 47 | paths_to_mutate="emptylog" 48 | runner="pytest" 49 | 50 | [project.urls] 51 | 'Source' = 'https://github.com/pomponchik/emptylog' 52 | 'Tracker' = 'https://github.com/pomponchik/emptylog/issues' 53 | -------------------------------------------------------------------------------- /emptylog/loggers_group.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Tuple, Callable, Any 3 | from threading import Lock 4 | from collections.abc import Iterator 5 | 6 | from printo import descript_data_object 7 | 8 | from emptylog.protocols import LoggerProtocol, LoggerMethodProtocol 9 | from emptylog.abstract_logger import AbstractLogger 10 | 11 | 12 | if sys.version_info < (3, 9): 13 | GroupIterator = Iterator # pragma: no cover 14 | else: 15 | GroupIterator = Iterator[LoggerProtocol] # pragma: no cover 16 | 17 | class LoggersGroup(AbstractLogger): 18 | loggers: Tuple[LoggerProtocol, ...] # pragma: no cover 19 | 20 | def __init__(self, *loggers: LoggerProtocol) -> None: 21 | for logger in loggers: 22 | if not isinstance(logger, LoggerProtocol): 23 | raise TypeError(f'A logger group can only be created from loggers. You passed {repr(logger)} ({type(logger).__name__}).') 24 | 25 | self.loggers = loggers 26 | self.lock = Lock() 27 | 28 | def __repr__(self) -> str: 29 | return descript_data_object(type(self).__name__, self.loggers, {}) 30 | 31 | def __len__(self) -> int: 32 | return len(self.loggers) 33 | 34 | def __iter__(self) -> GroupIterator: # type: ignore[type-arg, unused-ignore] 35 | yield from self.loggers 36 | 37 | def debug(self, message: str, *args: Any, **kwargs: Any) -> None: 38 | self.run_loggers(lambda x: x.debug, message, *args, **kwargs) 39 | 40 | def info(self, message: str, *args: Any, **kwargs: Any) -> None: 41 | self.run_loggers(lambda x: x.info, message, *args, **kwargs) 42 | 43 | def warning(self, message: str, *args: Any, **kwargs: Any) -> None: 44 | self.run_loggers(lambda x: x.warning, message, *args, **kwargs) 45 | 46 | def error(self, message: str, *args: Any, **kwargs: Any) -> None: 47 | self.run_loggers(lambda x: x.error, message, *args, **kwargs) 48 | 49 | def exception(self, message: str, *args: Any, **kwargs: Any) -> None: 50 | self.run_loggers(lambda x: x.exception, message, *args, **kwargs) 51 | 52 | def critical(self, message: str, *args: Any, **kwargs: Any) -> None: 53 | self.run_loggers(lambda x: x.critical, message, *args, **kwargs) 54 | 55 | def run_loggers(self, get_method: Callable[[LoggerProtocol], LoggerMethodProtocol], message: str, *args: Any, **kwargs: Any) -> None: 56 | with self.lock: 57 | for logger in self.loggers: 58 | method = get_method(logger) 59 | method(message, *args, **kwargs) 60 | -------------------------------------------------------------------------------- /tests/test_memory_logger.py: -------------------------------------------------------------------------------- 1 | from emptylog import MemoryLogger, LoggerProtocol 2 | from emptylog.memory_logger import LoggerCallData 3 | 4 | 5 | def test_memory_logger_is_logger(): 6 | assert isinstance(MemoryLogger(), LoggerProtocol) 7 | 8 | 9 | def test_memory_logger_is_working(): 10 | attribute_names = [ 11 | 'debug', 12 | 'info', 13 | 'warning', 14 | 'error', 15 | 'exception', 16 | 'critical', 17 | ] 18 | 19 | logger = MemoryLogger() 20 | 21 | for name in attribute_names: 22 | method = getattr(logger, name) 23 | 24 | assert callable(method) 25 | 26 | for number in range(3): 27 | method(f'kek_{name}', 'lol', 'cheburek', name, pek='mek', kekokek=name) 28 | 29 | assert len(logger.data.debug) == 3 30 | assert len(logger.data.info) == 3 31 | assert len(logger.data.warning) == 3 32 | assert len(logger.data.error) == 3 33 | assert len(logger.data.exception) == 3 34 | assert len(logger.data.critical) == 3 35 | 36 | assert logger.data.debug[0] == logger.data.debug[1] == logger.data.debug[2] 37 | assert logger.data.info[0] == logger.data.info[1] == logger.data.info[2] 38 | assert logger.data.warning[0] == logger.data.warning[1] == logger.data.warning[2] 39 | assert logger.data.error[0] == logger.data.error[1] == logger.data.error[2] 40 | assert logger.data.exception[0] == logger.data.exception[1] == logger.data.exception[2] 41 | assert logger.data.critical[0] == logger.data.critical[1] == logger.data.critical[2] 42 | 43 | assert logger.data.debug[0] == LoggerCallData(message='kek_debug', args=('lol', 'cheburek', 'debug'), kwargs={'pek': 'mek', 'kekokek': 'debug'}) 44 | assert logger.data.info[0] == LoggerCallData(message='kek_info', args=('lol', 'cheburek', 'info'), kwargs={'pek': 'mek', 'kekokek': 'info'}) 45 | assert logger.data.warning[0] == LoggerCallData(message='kek_warning', args=('lol', 'cheburek', 'warning'), kwargs={'pek': 'mek', 'kekokek': 'warning'}) 46 | assert logger.data.error[0] == LoggerCallData(message='kek_error', args=('lol', 'cheburek', 'error'), kwargs={'pek': 'mek', 'kekokek': 'error'}) 47 | assert logger.data.exception[0] == LoggerCallData(message='kek_exception', args=('lol', 'cheburek', 'exception'), kwargs={'pek': 'mek', 'kekokek': 'exception'}) 48 | assert logger.data.critical[0] == LoggerCallData(message='kek_critical', args=('lol', 'cheburek', 'critical'), kwargs={'pek': 'mek', 'kekokek': 'critical'}) 49 | 50 | 51 | def test_repr_memory_logger(): 52 | assert repr(MemoryLogger()) == 'MemoryLogger()' 53 | -------------------------------------------------------------------------------- /tests/test_abstract_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from full_match import match 5 | from loguru import logger as loguru_logger 6 | 7 | from emptylog.abstract_logger import AbstractLogger 8 | from emptylog import EmptyLogger, LoggersGroup, MemoryLogger, PrintingLogger 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ['first_logger'], 13 | ( 14 | (EmptyLogger(),), 15 | (MemoryLogger(),), 16 | (PrintingLogger(),), 17 | ), 18 | ) 19 | @pytest.mark.parametrize( 20 | ['second_logger'], 21 | ( 22 | (EmptyLogger(),), 23 | (MemoryLogger(),), 24 | (PrintingLogger(),), 25 | (logging,), 26 | (logging.getLogger('kek'),), 27 | (loguru_logger,), 28 | ), 29 | ) 30 | def test_sum_of_inner_loggers(first_logger, second_logger): 31 | sum = first_logger + second_logger 32 | 33 | assert isinstance(sum, LoggersGroup) 34 | 35 | assert sum is not first_logger 36 | assert sum is not second_logger 37 | 38 | assert sum.loggers[0] is first_logger 39 | assert sum.loggers[1] is second_logger 40 | 41 | 42 | @pytest.mark.parametrize( 43 | ['first_logger'], 44 | ( 45 | (logging,), 46 | (logging.getLogger('kek'),), 47 | (loguru_logger,), 48 | ), 49 | ) 50 | @pytest.mark.parametrize( 51 | ['second_logger'], 52 | ( 53 | (EmptyLogger(),), 54 | (MemoryLogger(),), 55 | (PrintingLogger(),), 56 | ), 57 | ) 58 | def test_sum_with_another_loggers_as_first_operand(first_logger, second_logger): 59 | sum = first_logger + second_logger 60 | 61 | assert isinstance(sum, LoggersGroup) 62 | 63 | assert sum is not first_logger 64 | assert sum is not second_logger 65 | 66 | assert sum.loggers[0] is first_logger 67 | assert sum.loggers[1] is second_logger 68 | 69 | 70 | @pytest.mark.parametrize( 71 | ['logger'], 72 | ( 73 | (EmptyLogger(),), 74 | (LoggersGroup(),), 75 | (MemoryLogger(),), 76 | (PrintingLogger(),), 77 | ), 78 | ) 79 | def test_all_loggers_are_instances_of_abstract_logger(logger): 80 | assert isinstance(logger, AbstractLogger) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ['logger'], 85 | ( 86 | (EmptyLogger(),), 87 | (LoggersGroup(),), 88 | (MemoryLogger(),), 89 | (PrintingLogger(),), 90 | ), 91 | ) 92 | @pytest.mark.parametrize( 93 | ['wrong_operand'], 94 | ( 95 | (1,), 96 | ('kek',), 97 | (None,), 98 | ), 99 | ) 100 | def test_sum_with_wrong_first_operand(logger, wrong_operand): 101 | with pytest.raises(NotImplementedError, match=match('The addition operation is defined only for loggers.')): 102 | wrong_operand + logger 103 | 104 | 105 | @pytest.mark.parametrize( 106 | ['logger'], 107 | ( 108 | (EmptyLogger(),), 109 | (LoggersGroup(),), 110 | (MemoryLogger(),), 111 | (PrintingLogger(),), 112 | ), 113 | ) 114 | @pytest.mark.parametrize( 115 | ['wrong_operand'], 116 | ( 117 | (1,), 118 | ('kek',), 119 | (None,), 120 | ), 121 | ) 122 | def test_sum_with_wrong_second_operand(logger, wrong_operand): 123 | with pytest.raises(NotImplementedError, match=match('The addition operation is defined only for loggers.')): 124 | logger + wrong_operand 125 | 126 | 127 | def test_sum_of_three_loggers(): 128 | first_logger = EmptyLogger() 129 | second_logger = MemoryLogger() 130 | third_logger = PrintingLogger() 131 | 132 | sum = first_logger + second_logger + third_logger 133 | 134 | assert isinstance(sum, LoggersGroup) 135 | 136 | assert len(sum) == 3 137 | assert len(sum.loggers) == 3 138 | 139 | assert sum.loggers[0] is first_logger 140 | assert sum.loggers[1] is second_logger 141 | assert sum.loggers[2] is third_logger 142 | -------------------------------------------------------------------------------- /tests/test_printing_logger.py: -------------------------------------------------------------------------------- 1 | import io 2 | import re 3 | from contextlib import redirect_stdout 4 | 5 | import pytest 6 | 7 | from emptylog import PrintingLogger, LoggerProtocol 8 | 9 | 10 | def test_printing_logger_is_logger(): 11 | assert isinstance(PrintingLogger(), LoggerProtocol) 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ['method', 'result_tail'], 16 | ( 17 | (PrintingLogger().debug, ' | DEBUG | kek'), 18 | (PrintingLogger().info, ' | INFO | kek'), 19 | (PrintingLogger().warning, ' | WARNING | kek'), 20 | (PrintingLogger().error, ' | ERROR | kek'), 21 | (PrintingLogger().exception, ' | EXCEPTION | kek'), 22 | (PrintingLogger().critical, ' | CRITICAL | kek'), 23 | 24 | (PrintingLogger(separator='*').debug, ' * DEBUG * kek'), 25 | (PrintingLogger(separator='*').info, ' * INFO * kek'), 26 | (PrintingLogger(separator='*').warning, ' * WARNING * kek'), 27 | (PrintingLogger(separator='*').error, ' * ERROR * kek'), 28 | (PrintingLogger(separator='*').exception, ' * EXCEPTION * kek'), 29 | (PrintingLogger(separator='*').critical, ' * CRITICAL * kek'), 30 | ), 31 | ) 32 | def test_check_simple_output(method, result_tail): 33 | # this code is adapted from there: https://stackoverflow.com/a/66683635 34 | remove_suffix = lambda input_string, suffix: input_string[:-len(suffix)] if suffix and input_string.endswith(suffix) else input_string # noqa: E731 35 | 36 | buffer = io.StringIO() 37 | with redirect_stdout(buffer): 38 | method('kek') 39 | 40 | printed = buffer.getvalue() 41 | 42 | assert printed.endswith(result_tail + '\n') 43 | 44 | time_stamp = remove_suffix(printed, result_tail + '\n') # expected format: 2024-07-08 19:09:48.226667 45 | 46 | assert len(time_stamp.split()) == 2 47 | 48 | date = time_stamp.split()[0] 49 | time = time_stamp.split()[1] 50 | 51 | assert re.match(r'[\d]{4}-[\d]{2}-[\d]{2}', date) is not None 52 | 53 | time_before_dot = time.split('.')[0] 54 | time_after_dot = time.split('.')[1] 55 | 56 | assert len(time.split('.')) == 2 57 | assert re.match(r'[\d]{2}:[\d]{2}:[\d]{2}', time_before_dot) is not None 58 | assert time_after_dot.isdigit() 59 | 60 | 61 | @pytest.mark.parametrize( 62 | ['get_method', 'result_tail'], 63 | ( 64 | (lambda x: x.debug, ' | DEBUG | kek'), 65 | (lambda x: x.info, ' | INFO | kek'), 66 | (lambda x: x.warning, ' | WARNING | kek'), 67 | (lambda x: x.error, ' | ERROR | kek'), 68 | (lambda x: x.exception, ' | EXCEPTION | kek'), 69 | (lambda x: x.critical, ' | CRITICAL | kek'), 70 | ), 71 | ) 72 | def test_forward_output(get_method, result_tail): 73 | # this code is adapted from there: https://stackoverflow.com/a/66683635 74 | remove_suffix = lambda input_string, suffix: input_string[:-len(suffix)] if suffix and input_string.endswith(suffix) else input_string # noqa: E731 75 | 76 | counter = 0 77 | last_line = '' 78 | 79 | def callback(line): 80 | nonlocal counter 81 | nonlocal last_line 82 | counter += 1 83 | last_line = line 84 | 85 | logger = PrintingLogger(printing_callback=callback) 86 | method = get_method(logger) 87 | 88 | buffer = io.StringIO() 89 | with redirect_stdout(buffer): 90 | method('kek') 91 | 92 | assert buffer.getvalue() == '' 93 | 94 | assert last_line.endswith(result_tail + '\n') 95 | 96 | time_stamp = remove_suffix(last_line, result_tail + '\n') # expected format: 2024-07-08 19:09:48.226667 97 | 98 | assert len(time_stamp.split()) == 2 99 | 100 | date = time_stamp.split()[0] 101 | time = time_stamp.split()[1] 102 | 103 | assert re.match(r'[\d]{4}-[\d]{2}-[\d]{2}', date) is not None 104 | 105 | time_before_dot = time.split('.')[0] 106 | time_after_dot = time.split('.')[1] 107 | 108 | assert len(time.split('.')) == 2 109 | assert re.match(r'[\d]{2}:[\d]{2}:[\d]{2}', time_before_dot) is not None 110 | assert time_after_dot.isdigit() 111 | 112 | 113 | @pytest.mark.parametrize( 114 | ['method', 'result_tail'], 115 | ( 116 | (PrintingLogger().debug, ' | DEBUG | kek'), 117 | (PrintingLogger().info, ' | INFO | kek'), 118 | (PrintingLogger().warning, ' | WARNING | kek'), 119 | (PrintingLogger().error, ' | ERROR | kek'), 120 | (PrintingLogger().exception, ' | EXCEPTION | kek'), 121 | (PrintingLogger().critical, ' | CRITICAL | kek'), 122 | ), 123 | ) 124 | def test_multiple_lines(method, result_tail): 125 | number_of_iterations = 10 126 | lines = [] 127 | 128 | for number in range(number_of_iterations): 129 | buffer = io.StringIO() 130 | with redirect_stdout(buffer): 131 | method('kek') 132 | 133 | lines.append(buffer.getvalue()) 134 | 135 | assert len(lines) == number_of_iterations 136 | 137 | assert all([x.endswith(result_tail + '\n') for x in lines]) 138 | 139 | 140 | def test_repr_printing_logger(): 141 | assert repr(PrintingLogger()) == 'PrintingLogger()' 142 | -------------------------------------------------------------------------------- /tests/test_loggers_group.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from full_match import match 5 | from loguru import logger as loguru_logger 6 | 7 | from emptylog import LoggersGroup, MemoryLogger 8 | 9 | 10 | def test_len_of_group(): 11 | assert len(LoggersGroup()) == 0 12 | assert len(LoggersGroup(MemoryLogger())) == 1 13 | assert len(LoggersGroup(MemoryLogger(), MemoryLogger())) == 2 14 | assert len(LoggersGroup(MemoryLogger(), MemoryLogger(), MemoryLogger())) == 3 15 | assert len(LoggersGroup(MemoryLogger(), MemoryLogger(), MemoryLogger(), MemoryLogger())) == 4 16 | 17 | assert len(LoggersGroup() + LoggersGroup()) == 0 18 | assert len(LoggersGroup() + MemoryLogger()) == 1 19 | assert len(MemoryLogger() + MemoryLogger()) == 2 20 | assert len(MemoryLogger() + MemoryLogger() + MemoryLogger()) == 3 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ['wrong_logger', 'exception_message'], 25 | ( 26 | (1, 'A logger group can only be created from loggers. You passed 1 (int).'), 27 | ('kek', 'A logger group can only be created from loggers. You passed \'kek\' (str).'), 28 | (None, 'A logger group can only be created from loggers. You passed None (NoneType).'), 29 | ), 30 | ) 31 | def test_create_group_with_not_loggers(wrong_logger, exception_message): 32 | with pytest.raises(TypeError, match=match(exception_message)): 33 | LoggersGroup(wrong_logger) 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ['get_method'], 38 | ( 39 | (lambda x: x.debug,), 40 | (lambda x: x.info,), 41 | (lambda x: x.warning,), 42 | (lambda x: x.error,), 43 | (lambda x: x.exception,), 44 | (lambda x: x.critical,), 45 | ), 46 | ) 47 | def test_run_group_of_memory_loggers(get_method): 48 | first_internal_logger = MemoryLogger() 49 | second_internal_logger = MemoryLogger() 50 | group = LoggersGroup(first_internal_logger, second_internal_logger) 51 | 52 | get_method(group)('lol', 'kek', cheburek='pek') 53 | 54 | for internal_logger in first_internal_logger, second_internal_logger: 55 | assert len(get_method(internal_logger.data)) == 1 56 | assert get_method(internal_logger.data)[0].message == 'lol' 57 | assert get_method(internal_logger.data)[0].args == ('kek',) 58 | assert get_method(internal_logger.data)[0].kwargs == {'cheburek': 'pek'} 59 | 60 | 61 | def test_repr_loggers_group(): 62 | assert repr(LoggersGroup()) == 'LoggersGroup()' 63 | assert repr(LoggersGroup(LoggersGroup())) == 'LoggersGroup(LoggersGroup())' 64 | assert repr(LoggersGroup(MemoryLogger())) == 'LoggersGroup(MemoryLogger())' 65 | assert repr(LoggersGroup(MemoryLogger(), MemoryLogger())) == 'LoggersGroup(MemoryLogger(), MemoryLogger())' 66 | assert repr(LoggersGroup(MemoryLogger(), MemoryLogger(), MemoryLogger())) == 'LoggersGroup(MemoryLogger(), MemoryLogger(), MemoryLogger())' 67 | 68 | 69 | def test_empty_group_plus_empty_group(): 70 | assert type(LoggersGroup() + LoggersGroup()) is LoggersGroup 71 | assert (LoggersGroup() + LoggersGroup()).loggers == () 72 | 73 | 74 | def test_not_empty_group_plus_empty_group(): 75 | first_internal_logger = MemoryLogger() 76 | second_internal_logger = MemoryLogger() 77 | 78 | assert type(LoggersGroup(first_internal_logger) + LoggersGroup()) is LoggersGroup 79 | assert type(LoggersGroup(first_internal_logger, second_internal_logger) + LoggersGroup()) is LoggersGroup 80 | 81 | assert len((LoggersGroup(first_internal_logger) + LoggersGroup()).loggers) == 1 82 | assert len((LoggersGroup(first_internal_logger, second_internal_logger) + LoggersGroup()).loggers) == 2 83 | 84 | assert (LoggersGroup(first_internal_logger) + LoggersGroup()).loggers[0] is first_internal_logger 85 | assert (LoggersGroup(first_internal_logger, second_internal_logger) + LoggersGroup()).loggers[0] is first_internal_logger 86 | assert (LoggersGroup(first_internal_logger, second_internal_logger) + LoggersGroup()).loggers[1] is second_internal_logger 87 | 88 | 89 | def test_empty_group_plus_not_empty_group(): 90 | first_internal_logger = MemoryLogger() 91 | second_internal_logger = MemoryLogger() 92 | 93 | assert type(LoggersGroup() + LoggersGroup(first_internal_logger)) is LoggersGroup 94 | assert type(LoggersGroup() + LoggersGroup(first_internal_logger, second_internal_logger)) is LoggersGroup 95 | 96 | assert len((LoggersGroup() + LoggersGroup(first_internal_logger)).loggers) == 1 97 | assert len((LoggersGroup() + LoggersGroup(first_internal_logger, second_internal_logger)).loggers) == 2 98 | 99 | assert (LoggersGroup() + LoggersGroup(first_internal_logger)).loggers[0] is first_internal_logger 100 | assert (LoggersGroup() + LoggersGroup(first_internal_logger, second_internal_logger)).loggers[0] is first_internal_logger 101 | assert (LoggersGroup() + LoggersGroup(first_internal_logger, second_internal_logger)).loggers[1] is second_internal_logger 102 | 103 | 104 | def test_empty_group_plus_another_logger(): 105 | another_logger = MemoryLogger() 106 | 107 | assert type(LoggersGroup() + another_logger) is LoggersGroup 108 | assert len((LoggersGroup() + another_logger).loggers) == 1 109 | assert (LoggersGroup() + another_logger).loggers[0] is another_logger 110 | 111 | 112 | def test_another_logger_plus_empty_group(): 113 | another_logger = MemoryLogger() 114 | 115 | assert type(another_logger + LoggersGroup()) is LoggersGroup 116 | assert len((another_logger + LoggersGroup()).loggers) == 1 117 | assert len(another_logger + LoggersGroup()) == 1 118 | assert (another_logger + LoggersGroup()).loggers[0] is another_logger 119 | 120 | 121 | @pytest.mark.parametrize( 122 | ['third_party_logger'], 123 | ( 124 | (loguru_logger,), 125 | (logging,), 126 | (logging.getLogger('kek'),), 127 | ), 128 | ) 129 | def test_empty_group_plus_third_party_logger(third_party_logger): 130 | first_group = LoggersGroup() 131 | 132 | sum = first_group + third_party_logger 133 | 134 | assert type(sum) is LoggersGroup 135 | assert sum is not first_group 136 | assert len(sum.loggers) == 1 137 | assert len(sum) == 1 138 | assert sum.loggers[0] is third_party_logger 139 | 140 | 141 | @pytest.mark.parametrize( 142 | ['third_party_logger'], 143 | ( 144 | (loguru_logger,), 145 | (logging,), 146 | (logging.getLogger('kek'),), 147 | ), 148 | ) 149 | def test_third_party_logger_plus_empty_group(third_party_logger): 150 | first_group = LoggersGroup() 151 | 152 | sum = third_party_logger + first_group 153 | 154 | assert type(sum) is LoggersGroup 155 | assert sum is not first_group 156 | assert len(sum.loggers) == 1 157 | assert len(sum) == 1 158 | assert sum.loggers[0] is third_party_logger 159 | 160 | 161 | @pytest.mark.parametrize( 162 | ['loggers'], 163 | ( 164 | ([loguru_logger, logging, logging.getLogger('kek')],), 165 | ([MemoryLogger(), MemoryLogger()],), 166 | ([MemoryLogger()],), 167 | ([],), 168 | ), 169 | ) 170 | def test_iteration_by_group(loggers): 171 | group = LoggersGroup(*loggers) 172 | 173 | assert loggers == [x for x in group] 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://raw.githubusercontent.com/pomponchik/emptylog/develop/docs/assets/logo_5.svg) 2 | 3 | [![Downloads](https://static.pepy.tech/badge/emptylog/month)](https://pepy.tech/project/emptylog) 4 | [![Downloads](https://static.pepy.tech/badge/emptylog)](https://pepy.tech/project/emptylog) 5 | [![Coverage Status](https://coveralls.io/repos/github/pomponchik/emptylog/badge.svg?branch=main)](https://coveralls.io/github/pomponchik/emptylog?branch=main) 6 | [![Lines of code](https://sloc.xyz/github/pomponchik/emptylog/?category=code)](https://github.com/boyter/scc/) 7 | [![Hits-of-Code](https://hitsofcode.com/github/pomponchik/emptylog?branch=main)](https://hitsofcode.com/github/pomponchik/emptylog/view?branch=main) 8 | [![Test-Package](https://github.com/pomponchik/emptylog/actions/workflows/tests_and_coverage.yml/badge.svg)](https://github.com/pomponchik/emptylog/actions/workflows/tests_and_coverage.yml) 9 | [![Python versions](https://img.shields.io/pypi/pyversions/emptylog.svg)](https://pypi.python.org/pypi/emptylog) 10 | [![PyPI version](https://badge.fury.io/py/emptylog.svg)](https://badge.fury.io/py/emptylog) 11 | [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 12 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 13 | 14 | This library is designed to extend the capabilities of the built-in [`logging`](https://docs.python.org/3/library/logging.html) library. 15 | 16 | One of the important problems that it solves is the fact that almost no one tests logging in their programs. Are you sure that your program logs everything you need? Programmers cover with tests what they consider to be the basic logic of the program. Logging problems are usually detected only when something is on fire, and then you realize that there are not enough logs, or the wrong thing is being logged. On the contrary, this library makes logging as much a test-friendly part of your program as regular logic. 17 | 18 | Here are some of the features it provides: 19 | 20 | - A [universal logger protocol](#universal-logger-protocol) that allows you to replace one logger with another without typing violations. In tests, you can replace the original logger with a [logger that remembers its calls](#memory-logger) to check that logging is correct. 21 | - An [empty logger]((#empty-logger)) that does nothing when you call it. It is useful for writing library functions where the user can pass their logger, but there is no logging by default. 22 | - A [memory logger](#memory-logger) that remembers all the times it was called. To verify that your code is correctly logged in, pass it a memory logger object instead of the default logger, and then check how it was used. 23 | - A [printing logger](#printing-logger) is a "toy version" of a real logger that you can use to visualize all logger calls inside your test. 24 | - All loggers presented in this library can be easily [combined](#summation-of-loggers) using the "+" symbol. 25 | 26 | 27 | ## Table of contents 28 | 29 | - [**Installing**](#installing) 30 | - [**Universal Logger Protocol**](#universal-logger-protocol) 31 | - [**Empty Logger**](#empty-logger) 32 | - [**Memory Logger**](#memory-logger) 33 | - [**Printing Logger**](#printing-logger) 34 | - [**Summation of loggers**](#summation-of-loggers) 35 | 36 | 37 | ## Installing 38 | 39 | Install it from [Pypi](https://pypi.org/project/emptylog/): 40 | 41 | ```bash 42 | pip install emptylog 43 | ``` 44 | 45 | You can also quickly try out this and other packages without having to install using [instld](https://github.com/pomponchik/instld). 46 | 47 | 48 | ## Universal Logger Protocol 49 | 50 | Easily check whether an object is a logger using the protocol. The protocol contains 6 classic logger methods: 51 | 52 | ```python 53 | def debug(message: str, *args: Any, **kwargs: Any) -> None: pass 54 | def info(message: str, *args: Any, **kwargs: Any) -> None: pass 55 | def warning(message: str, *args: Any, **kwargs: Any) -> None: pass 56 | def error(message: str, *args: Any, **kwargs: Any) -> None: pass 57 | def exception(message: str, *args: Any, **kwargs: Any) -> None: pass 58 | def critical(message: str, *args: Any, **kwargs: Any) -> None: pass 59 | ``` 60 | 61 | The protocol is verifiable in runtime by the [`isinstance`](https://docs.python.org/3/library/functions.html#isinstance) function. Let's check this on a regular logger from `logging`: 62 | 63 | ```python 64 | import logging 65 | from emptylog import LoggerProtocol 66 | 67 | print(isinstance(logging.getLogger('some_name'), LoggerProtocol)) 68 | #> True 69 | ``` 70 | 71 | This also works for third-party loggers with the same signature. Let's try it on [loguru](https://github.com/Delgan/loguru): 72 | 73 | ```python 74 | from loguru import logger 75 | from emptylog import LoggerProtocol 76 | 77 | print(isinstance(logger, LoggerProtocol)) 78 | #> True 79 | ``` 80 | 81 | And of course, you can use the protocol for type hints: 82 | 83 | ```python 84 | def function(logger: LoggerProtocol): 85 | logger.info('There was an earthquake in Japan, check the prices of hard drives!') 86 | ``` 87 | 88 | The protocol can be used for static checks by any tool you prefer, such as [`mypy`](https://github.com/python/mypy). 89 | 90 | 91 | ## Empty Logger 92 | 93 | `EmptyLogger` is the simplest implementation of the [logger protocol](#universal-logger-protocol). When calling logging methods from an object of this class, nothing happens. You can use it as a stub, for example, when defining functions: 94 | 95 | ```python 96 | from emptylog import EmptyLogger, LoggerProtocol 97 | 98 | def function(logger: LoggerProtocol = EmptyLogger()): 99 | logger.error('Kittens have spilled milk, you need to pour more.') 100 | ``` 101 | 102 | 103 | ## Memory Logger 104 | 105 | `MemoryLogger` is a special class designed for tests. Its difference from [`EmptyLogger`](#empty-logger) is that it remembers all the times it was called. 106 | 107 | The call history is stored in the `data` attribute and sorted by logger method names: 108 | 109 | ```python 110 | from emptylog import MemoryLogger 111 | 112 | logger = MemoryLogger() 113 | 114 | logger.error('Joe Biden forgot his name again.') 115 | logger.error('And again.') 116 | logger.info("Joe, remember, you're Joe.") 117 | 118 | print(logger.data) 119 | #> LoggerAccumulatedData(debug=[], info=[LoggerCallData(message="Joe, remember, you're Joe.", args=(), kwargs={})], warning=[], error=[LoggerCallData(message='Joe Biden forgot his name again.', args=(), kwargs={}), LoggerCallData(message='And again.', args=(), kwargs={})], exception=[], critical=[]) 120 | 121 | print(logger.data.info[0].message) 122 | #> Joe, remember, you're Joe. 123 | print(logger.data.error[0].message) 124 | #> Joe Biden forgot his name again. 125 | print(logger.data.info[0].args) 126 | #> () 127 | print(logger.data.info[0].kwargs) 128 | #> {} 129 | ``` 130 | 131 | You can find out the total number of logs saved by `MemoryLogger` by applying the [`len()`](https://docs.python.org/3/library/functions.html#len) function to the `data` attribute: 132 | 133 | ```python 134 | logger = MemoryLogger() 135 | 136 | logger.warning("Are you ready, kids?") 137 | logger.info("Aye, aye, Captain!") 138 | logger.error("I can't hear you!") 139 | logger.info("Aye, aye, Captain!") 140 | logger.debug("Oh!") 141 | 142 | print(len(logger.data)) 143 | #> 5 144 | ``` 145 | 146 | 147 | ## Printing Logger 148 | 149 | `PrintingLogger` is the simplest logger designed for printing to the console. You cannot control the format or direction of the output, or send logs to a special microservice that will forward them to a long-term storage with indexing support. No, here you can only get basic output to the console and nothing else. Here is an example: 150 | 151 | ```python 152 | from emptylog import PrintingLogger 153 | 154 | logger = PrintingLogger() 155 | 156 | logger.debug("I ate a strange pill found under my grandfather's pillow.") 157 | #> 2024-07-08 20:52:31.342048 | DEBUG | I ate a strange pill found under my grandfather's pillow. 158 | logger.info("Everything seems to be fine.") 159 | #> 2024-07-08 20:52:31.342073 | INFO | Everything seems to be fine. 160 | logger.error("My grandfather beat me up. He seems to be breathing fire.") 161 | #> 2024-07-08 20:52:31.342079 | ERROR | My grandfather beat me up. He seems to be breathing fire. 162 | ``` 163 | 164 | As you can see, 3 things are output to the console: the exact time, the logging level, and the message. The message does not support extrapolation. Also, you won't see any additional arguments here that could have been passed to the method. 165 | 166 | > ⚠️ Do not use this logger in production. It is intended solely for the purposes of debugging or testing of software. 167 | 168 | If necessary, you can change the behavior of the logger by passing it a callback, which is called for the finished message to print it to the console. Instead of the original function (the usual [`print`](https://docs.python.org/3/library/functions.html#print) function is used under the hood), you can pass something more interesting (the code example uses the [`termcolor`](https://github.com/termcolor/termcolor) library): 169 | 170 | ```python 171 | from termcolor import colored 172 | 173 | def callback(string: str) -> None: 174 | print(colored(string, 'green'), end='') 175 | 176 | logger = PrintingLogger(printing_callback=callback) 177 | 178 | logger.info('Hello, the colored world!') 179 | #> 2024-07-09 11:20:03.693837 | INFO | Hello, the colored world! 180 | # You can't see it here, but believe me, if you repeat the code at home, the output in the console will be green! 181 | ``` 182 | 183 | 184 | ## Summation of loggers 185 | 186 | All loggers represented in this library can be grouped together. To do this, just use the "+" operator: 187 | 188 | ```python 189 | from emptylog import PrintingLogger, MemoryLogger 190 | 191 | logger = PrintingLogger() + MemoryLogger() 192 | print(logger) 193 | #> LoggersGroup(PrintingLogger(), MemoryLogger()) 194 | ``` 195 | 196 | The group object also implements the [logger protocol](#universal-logger-protocol). If you use it as a logger, it will alternately call the appropriate methods from the loggers nested in it: 197 | 198 | ```python 199 | printing_logger = PrintingLogger() 200 | memory_logger = MemoryLogger() 201 | 202 | super_logger = printing_logger + memory_logger 203 | 204 | super_logger.info('Together we are a force!') 205 | #> 2024-07-10 16:49:21.247290 | INFO | Together we are a force! 206 | print(memory_logger.data.info[0].message) 207 | #> Together we are a force! 208 | ``` 209 | 210 | You can sum up more than 2 loggers. In this case, the number of nesting levels will not grow: 211 | 212 | ```python 213 | print(MemoryLogger() + MemoryLogger() + MemoryLogger()) 214 | #> LoggersGroup(MemoryLogger(), MemoryLogger(), MemoryLogger()) 215 | ``` 216 | 217 | You can also add any loggers from this library with loggers from other libraries, for example from the [standard library](https://docs.python.org/3/library/logging.html) or from [loguru](https://github.com/Delgan/loguru): 218 | 219 | ```python 220 | import logging 221 | from loguru import logger as loguru_logger 222 | 223 | print(MemoryLogger() + loguru_logger + logging.getLogger(__name__)) 224 | #> LoggersGroup(MemoryLogger(), )]>, ) 225 | ``` 226 | 227 | Finally, you can use a group as an iterable object, as well as find out the number of nested loggers in a standard way: 228 | 229 | ```python 230 | group = PrintingLogger() + MemoryLogger() 231 | 232 | print(len(group)) 233 | #> 2 234 | print([x for x in group]) 235 | #> [PrintingLogger(), MemoryLogger()] 236 | ``` 237 | -------------------------------------------------------------------------------- /docs/assets/logo_1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 43 | 44 | 48 | 55 | 62 | 69 | 76 | 83 | 90 | 97 | 104 | 111 | 118 | 125 | 132 | 139 | 146 | 153 | 160 | 167 | 174 | 181 | 188 | 195 | 202 | 209 | 216 | 223 | 230 | 237 | 244 | 251 | 258 | 265 | 272 | 279 | 286 | 293 | 300 | 307 | 314 | 321 | 328 | 335 | 342 | 349 | 356 | 363 | 370 | 377 | 384 | 391 | 398 | 405 | 412 | 419 | 426 | 433 | 440 | 447 | 454 | 461 | 468 | 475 | 482 | 489 | 496 | EMPTYLOG 504 | 505 | 506 | -------------------------------------------------------------------------------- /docs/assets/logo_2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 43 | 49 | 50 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 166 | 173 | 180 | 187 | 194 | 201 | 208 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 306 | 313 | 320 | 327 | 334 | 341 | 348 | 355 | 362 | 369 | 376 | 383 | 390 | 397 | 404 | 411 | 418 | 425 | 432 | 439 | 446 | 453 | 460 | 467 | 474 | 481 | 488 | 495 | 502 | EMPTYL G 510 | 515 | 516 | 517 | -------------------------------------------------------------------------------- /docs/assets/logo_4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 43 | 49 | 50 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 166 | 173 | 180 | 187 | 194 | 201 | 208 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 306 | 313 | 320 | 327 | 334 | 341 | 348 | 355 | 362 | 369 | 376 | 383 | 390 | 397 | 404 | 411 | 418 | 425 | 432 | 439 | 446 | 453 | 460 | 467 | 474 | 481 | 488 | 495 | 502 | EMPTYL G 510 | 515 | 516 | 517 | -------------------------------------------------------------------------------- /docs/assets/logo_3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 43 | 49 | 50 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 166 | 173 | 180 | 187 | 194 | 201 | 208 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 306 | 313 | 320 | 327 | 334 | 341 | 348 | 355 | 362 | 369 | 376 | 383 | 390 | 397 | 404 | 411 | 418 | 425 | 432 | 439 | 446 | 453 | 460 | 467 | 474 | 481 | 488 | 495 | 502 | EMPTYL G 510 | 515 | 516 | 517 | -------------------------------------------------------------------------------- /docs/assets/logo_5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 43 | 49 | 50 | 54 | 61 | 68 | 75 | 82 | 89 | 96 | 103 | 110 | 117 | 124 | 131 | 138 | 145 | 152 | 159 | 166 | 173 | 180 | 187 | 194 | 201 | 208 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 264 | 271 | 278 | 285 | 292 | 299 | 306 | 313 | 320 | 327 | 334 | 341 | 348 | 355 | 362 | 369 | 376 | 383 | 390 | 397 | 404 | 411 | 418 | 425 | 432 | 439 | 446 | 453 | 460 | 467 | 474 | 481 | 488 | 495 | 502 | 508 | 513 | 514 | 515 | --------------------------------------------------------------------------------