├── .coveragerc ├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── .style.yapf ├── .travis.yml ├── MANIFEST.in ├── Makefile ├── Projectfile ├── README.rst ├── classifiers.txt ├── demo.png ├── examples ├── basics.py ├── errors.py └── humanize.py ├── misc ├── logo.png └── logo.psd ├── mondrian ├── __init__.py ├── _version.py ├── contrib │ ├── __init__.py │ └── stackdriver.py ├── errors.py ├── filters.py ├── formatters.py ├── humanizer.py ├── levels.py ├── settings.py ├── styles.py └── term.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests └── .gitkeep /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | # Regexes for lines to exclude from consideration 6 | exclude_lines = 7 | # Have to re-enable the standard pragma 8 | pragma: no cover 9 | 10 | # Don't complain about missing debug-only code: 11 | def __repr__ 12 | if self\.debug 13 | 14 | # Don't complain if tests don't hit defensive assertion code: 15 | raise AbstractError 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Don't complain if non-runnable code isn't run: 20 | if 0: 21 | if __name__ == .__main__.: 22 | 23 | ignore_errors = True 24 | 25 | [html] 26 | directory = docs/_build/html/coverage 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.py] 9 | indent = ' ' 10 | indent_size = 4 11 | indent_style = space 12 | line_length = 120 13 | multi_line_output = 5 14 | 15 | [Makefile] 16 | indent_style = tab 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.iml 3 | *.pyc 4 | *.swp 5 | /.cache 6 | /.coverage 7 | /.idea 8 | /.medikit 9 | /.python*-* 10 | /.release 11 | /build 12 | /dist 13 | /docs/_build 14 | /htmlcov 15 | /pylint.html 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v3.2.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/psf/black 12 | rev: 20.8b1 13 | hooks: 14 | - id: black 15 | 16 | - repo: https://github.com/pycqa/isort 17 | rev: 5.6.4 18 | hooks: 19 | - id: isort 20 | name: isort (python) 21 | - id: isort 22 | name: isort (cython) 23 | types: [cython] 24 | - id: isort 25 | name: isort (pyi) 26 | types: [pyi] 27 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | column_limit = 120 4 | dedent_closing_brackets = true 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.5 4 | - 3.5-dev 5 | - 3.6 6 | - 3.6-dev 7 | - 3.7-dev 8 | - nightly 9 | install: 10 | - make install-dev 11 | - pip install coveralls 12 | script: 13 | - make clean test 14 | after_success: 15 | - coveralls 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Generated by Medikit 0.8.0 on 2020-11-27. 2 | # All changes will be overriden. 3 | # Edit Projectfile and run “make update” (or “medikit update”) to regenerate. 4 | 5 | 6 | PACKAGE ?= mondrian 7 | PYTHON ?= $(shell which python || echo python) 8 | PYTHON_BASENAME ?= $(shell basename $(PYTHON)) 9 | PYTHON_DIRNAME ?= $(shell dirname $(PYTHON)) 10 | PYTHON_REQUIREMENTS_FILE ?= requirements.txt 11 | PYTHON_REQUIREMENTS_INLINE ?= 12 | PYTHON_REQUIREMENTS_DEV_FILE ?= requirements-dev.txt 13 | PYTHON_REQUIREMENTS_DEV_INLINE ?= 14 | QUICK ?= 15 | PIP ?= $(PYTHON) -m pip 16 | PIP_INSTALL_OPTIONS ?= 17 | VERSION ?= $(shell git describe 2>/dev/null || git rev-parse --short HEAD) 18 | BLACK ?= $(shell which black || echo black) 19 | BLACK_OPTIONS ?= --line-length 120 20 | ISORT ?= $(PYTHON) -m isort 21 | ISORT_OPTIONS ?= --recursive --apply 22 | PYTEST ?= $(PYTHON_DIRNAME)/pytest 23 | PYTEST_OPTIONS ?= --capture=no --cov=$(PACKAGE) --cov-report html 24 | SPHINX_BUILD ?= $(PYTHON_DIRNAME)/sphinx-build 25 | SPHINX_OPTIONS ?= 26 | SPHINX_SOURCEDIR ?= docs 27 | SPHINX_BUILDDIR ?= $(SPHINX_SOURCEDIR)/_build 28 | MEDIKIT ?= $(PYTHON) -m medikit 29 | MEDIKIT_UPDATE_OPTIONS ?= 30 | MEDIKIT_VERSION ?= 0.8.0 31 | 32 | .PHONY: $(SPHINX_SOURCEDIR) clean format help install install-dev medikit quick release test update update-requirements 33 | 34 | install: .medikit/install ## Installs the project. 35 | .medikit/install: $(PYTHON_REQUIREMENTS_FILE) setup.py 36 | $(eval target := $(shell echo $@ | rev | cut -d/ -f1 | rev)) 37 | ifeq ($(filter quick,$(MAKECMDGOALS)),quick) 38 | @printf "Skipping \033[36m%s\033[0m because of \033[36mquick\033[0m target.\n" $(target) 39 | else ifneq ($(QUICK),) 40 | @printf "Skipping \033[36m%s\033[0m because \033[36m$$QUICK\033[0m is not empty.\n" $(target) 41 | else 42 | @printf "Applying \033[36m%s\033[0m target...\n" $(target) 43 | $(PIP) install $(PIP_INSTALL_OPTIONS) -U "pip ~=19.0,<19.2" wheel 44 | $(PIP) install $(PIP_INSTALL_OPTIONS) -U $(PYTHON_REQUIREMENTS_INLINE) -r $(PYTHON_REQUIREMENTS_FILE) 45 | @mkdir -p .medikit; touch $@ 46 | endif 47 | 48 | clean: ## Cleans up the working copy. 49 | rm -rf build dist *.egg-info .medikit/install .medikit/install-dev 50 | find . -name __pycache__ -type d | xargs rm -rf 51 | 52 | install-dev: .medikit/install-dev ## Installs the project (with dev dependencies). 53 | .medikit/install-dev: $(PYTHON_REQUIREMENTS_DEV_FILE) setup.py 54 | $(eval target := $(shell echo $@ | rev | cut -d/ -f1 | rev)) 55 | ifeq ($(filter quick,$(MAKECMDGOALS)),quick) 56 | @printf "Skipping \033[36m%s\033[0m because of \033[36mquick\033[0m target.\n" $(target) 57 | else ifneq ($(QUICK),) 58 | @printf "Skipping \033[36m%s\033[0m because \033[36m$$QUICK\033[0m is not empty.\n" $(target) 59 | else 60 | @printf "Applying \033[36m%s\033[0m target...\n" $(target) 61 | $(PIP) install $(PIP_INSTALL_OPTIONS) -U "pip ~=19.0,<19.2" wheel 62 | $(PIP) install $(PIP_INSTALL_OPTIONS) -U $(PYTHON_REQUIREMENTS_DEV_INLINE) -r $(PYTHON_REQUIREMENTS_DEV_FILE) 63 | @mkdir -p .medikit; touch $@ 64 | endif 65 | 66 | quick: # 67 | @printf "" 68 | 69 | format: install-dev ## Reformats the codebase (with black, isort). 70 | $(BLACK) $(BLACK_OPTIONS) . Projectfile 71 | $(ISORT) $(ISORT_OPTIONS) . Projectfile 72 | 73 | test: install-dev ## Runs the test suite. 74 | $(PYTEST) $(PYTEST_OPTIONS) tests 75 | 76 | $(SPHINX_SOURCEDIR): install-dev ## 77 | $(SPHINX_BUILD) -b html -D latex_paper_size=a4 $(SPHINX_OPTIONS) $(SPHINX_SOURCEDIR) $(SPHINX_BUILDDIR)/html 78 | 79 | release: medikit ## Runs the "release" pipeline. 80 | $(MEDIKIT) pipeline release start 81 | 82 | medikit: # Checks installed medikit version and updates it if it is outdated. 83 | @$(PYTHON) -c 'import medikit, pip, sys; from packaging.version import Version; sys.exit(0 if (Version(medikit.__version__) >= Version("$(MEDIKIT_VERSION)")) and (Version(pip.__version__) < Version("10")) else 1)' || $(PYTHON) -m pip install -U "pip ~=19.0,<19.2" "medikit>=$(MEDIKIT_VERSION)" 84 | 85 | update: medikit ## Update project artifacts using medikit. 86 | $(MEDIKIT) update $(MEDIKIT_UPDATE_OPTIONS) 87 | 88 | update-requirements: ## Update project artifacts using medikit, including requirements files. 89 | MEDIKIT_UPDATE_OPTIONS="--override-requirements" $(MAKE) update 90 | 91 | help: ## Shows available commands. 92 | @echo "Available commands:" 93 | @echo 94 | @grep -E '^[a-zA-Z_-]+:.*?##[\s]?.*$$' --no-filename $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"}; {printf " make \033[36m%-30s\033[0m %s\n", $$1, $$2}' 95 | @echo 96 | -------------------------------------------------------------------------------- /Projectfile: -------------------------------------------------------------------------------- 1 | # mondrian (see github.com/python-medikit) 2 | 3 | from medikit import listen, require, pipeline 4 | from medikit.steps.exec import System 5 | 6 | require("pytest") 7 | require("sphinx") 8 | require("format") 9 | 10 | with require("python") as python: 11 | python.setup( 12 | name="mondrian", 13 | description="Mondrian helps you to configure and use python's logging module once and for ever.", 14 | license="Apache License, Version 2.0", 15 | url="https://python-mondrian.github.io/", 16 | download_url="https://github.com/python-mondrian/mondrian/archive/{version}.tar.gz", 17 | author="Romain Dorgueil", 18 | author_email="romain@dorgueil.net", 19 | ) 20 | 21 | python.add_requirements("colorama >=0.3.7,<0.5", dev=["pre-commit ~=2.9.2"],) 22 | 23 | 24 | with require("make") as make: 25 | # Pipelines 26 | @listen(make.on_generate) 27 | def on_make_generate_pipelines(event): 28 | # Releases 29 | event.makefile.add_target( 30 | "release", 31 | "$(MEDIKIT) pipeline release start", 32 | deps=("medikit",), 33 | phony=True, 34 | doc='Runs the "release" pipeline.', 35 | ) 36 | 37 | with pipeline("release") as release: 38 | release.add(System('pre-commit run || true'), before="System('git add -p .', True)") 39 | 40 | 41 | # vim: ft=python: 42 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | mondrian 2 | ======== 3 | 4 | .. image:: https://app.fossa.io/api/projects/git%2Bgithub.com%2Fpython-mondrian%2Fmondrian.svg?type=shield 5 | :target: https://app.fossa.io/projects/git%2Bgithub.com%2Fpython-mondrian%2Fmondrian?ref=badge_shield 6 | :alt: License Status 7 | 8 | Mondrian helps you paint your standard python logger. 9 | 10 | .. image:: https://raw.githubusercontent.com/hartym/mondrian/master/demo.png 11 | :alt: Mondrian in action 12 | :width: 100% 13 | :align: center 14 | 15 | Enabling mondrian is simple and straightforward: 16 | 17 | .. code-block:: python 18 | 19 | import logging 20 | import mondrian 21 | 22 | logger = logging.getLogger() 23 | 24 | if __name__ == '__main__': 25 | mondrian.setup(excepthook=True) 26 | logger.setLevel(logging.INFO) 27 | 28 | logger.info('Hello, world.') 29 | 30 | 31 | License 32 | ======= 33 | 34 | .. image:: https://app.fossa.io/api/projects/git%2Bgithub.com%2Fpython-mondrian%2Fmondrian.svg?type=large 35 | :target: https://app.fossa.io/projects/git%2Bgithub.com%2Fpython-mondrian%2Fmondrian?ref=badge_large 36 | :alt: License Status 37 | 38 | -------------------------------------------------------------------------------- /classifiers.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/classifiers.txt -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/demo.png -------------------------------------------------------------------------------- /examples/basics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import mondrian 4 | 5 | 6 | def do_log(logger): 7 | logger.critical("This is a critical error.") 8 | logger.error("This is an error.") 9 | logger.warning("This is a warning.") 10 | logger.info("This is an info.") 11 | logger.debug("This is a debug information.") 12 | 13 | 14 | if __name__ == "__main__": 15 | mondrian.setup(excepthook=True) 16 | 17 | print("=== Logging using root logger, level=INFO ===") 18 | root_logger = logging.getLogger() 19 | root_logger.setLevel(logging.INFO) 20 | do_log(root_logger) 21 | print() 22 | 23 | print("=== Logging using foo.bar logger, level=DEBUG ===") 24 | logger = logging.getLogger("foo.bar") 25 | logger.setLevel(logging.DEBUG) 26 | do_log(logger) 27 | print() 28 | 29 | print("=== Logging an exception ===") 30 | raise RuntimeError("Woops, something bad happened...") 31 | -------------------------------------------------------------------------------- /examples/errors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import mondrian 5 | 6 | if __name__ == "__main__": 7 | mondrian.setup(excepthook=True) 8 | 9 | logger = logging.getLogger() 10 | logger.setLevel(logging.INFO) 11 | 12 | logger.info("Just so you know...") 13 | 14 | try: 15 | raise RuntimeError("""Hello, exception!\nFoo bar baz""") 16 | except Exception as exc: 17 | logger.exception("This is the message", exc_info=sys.exc_info()) 18 | 19 | raise ValueError("that is not caught...") 20 | -------------------------------------------------------------------------------- /examples/humanize.py: -------------------------------------------------------------------------------- 1 | import mondrian 2 | 3 | 4 | def main(): 5 | this_is_a_very_long_call_that_is_meant_to_break_everything_but_it_really_needs_to_be_damn_fucking_long_name() 6 | 7 | 8 | def this_is_a_very_long_call_that_is_meant_to_break_everything_but_it_really_needs_to_be_damn_fucking_long_name(): 9 | raise RuntimeError("This is an error.", "Run `DEBUG=1 ` to see the complete stack trace.") 10 | 11 | 12 | if __name__ == "__main__": 13 | mondrian.setup(excepthook=True) 14 | 15 | with mondrian.humanizer.humanize(): 16 | main() 17 | 18 | print(mondrian.humanizer.Success("Hello, world.")) 19 | -------------------------------------------------------------------------------- /misc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/misc/logo.png -------------------------------------------------------------------------------- /misc/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/misc/logo.psd -------------------------------------------------------------------------------- /mondrian/__init__.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import logging 3 | import sys 4 | 5 | from mondrian import errors, filters, formatters, humanizer, levels, settings, term 6 | from mondrian._version import __version__ 7 | 8 | __all__ = [ 9 | "__version__", 10 | "humanizer", 11 | "errors", 12 | "filters", 13 | "formatters", 14 | "levels", 15 | "settings", 16 | "setup", 17 | "setup_excepthook", 18 | "term", 19 | ] 20 | 21 | # Patch standard output/error if it's not supporting unicode 22 | # See: https://stackoverflow.com/questions/27347772/print-unicode-string-in-python-regardless-of-environment 23 | if sys.stdout.encoding is None or sys.stdout.encoding == "ANSI_X3.4-1968": 24 | sys.stdout = codecs.getwriter("UTF-8")(sys.stdout.buffer, errors="replace") 25 | sys.stderr = codecs.getwriter("UTF-8")(sys.stderr.buffer, errors="replace") 26 | 27 | 28 | def setup_excepthook(): 29 | """ 30 | Replace default python exception hook with our own. 31 | 32 | """ 33 | sys.excepthook = errors.excepthook 34 | 35 | 36 | is_setup = False 37 | 38 | 39 | def setup(*, level=None, colors=term.usecolors, excepthook=False, formatter=None): 40 | """ 41 | Setup mondrian log handlers. 42 | 43 | :param colors: bool 44 | :param excepthook: bool 45 | """ 46 | global is_setup 47 | 48 | logger = logging.getLogger() 49 | 50 | if not is_setup: 51 | handler = logging.StreamHandler(sys.stderr) 52 | 53 | if formatter: 54 | handler.setFormatter(formatter) 55 | else: 56 | for _level, _name in levels.NAMES.items(): 57 | logging.addLevelName(_level, _name) 58 | handler.setFormatter(formatters.Formatter()) 59 | 60 | handler.addFilter(filters.ColorFilter() if colors else filters.Filter()) 61 | logger.addHandler(handler) 62 | logging.captureWarnings(True) 63 | 64 | is_setup = True 65 | 66 | if excepthook: 67 | setup_excepthook() 68 | 69 | if level is None: 70 | # set default based on env 71 | logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO) 72 | elif level is not False: 73 | logger.setLevel(level) 74 | 75 | 76 | def getLogger(*args, colors=(not term.iswindows), excepthook=False, **kwargs): 77 | setup(colors=colors, excepthook=excepthook) 78 | return logging.getLogger(*args, **kwargs) 79 | -------------------------------------------------------------------------------- /mondrian/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.8.1" 2 | -------------------------------------------------------------------------------- /mondrian/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/mondrian/contrib/__init__.py -------------------------------------------------------------------------------- /mondrian/contrib/stackdriver.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | try: 4 | from pythonjsonlogger import jsonlogger 5 | except ImportError as exc: 6 | raise ImportError('To use "{}" you must install "python-json-logger" package.'.format(__name__)) from exc 7 | 8 | 9 | class StackdriverJsonFormatter(jsonlogger.JsonFormatter): 10 | def __init__(self, fmt="%(levelname) %(message)", style="%", *args, **kwargs): 11 | super().__init__(fmt=fmt, *args, **kwargs) 12 | 13 | def process_log_record(self, log_record): 14 | log_record["severity"] = log_record["levelname"] 15 | del log_record["levelname"] 16 | return super(StackdriverJsonFormatter, self).process_log_record(log_record) 17 | 18 | def add_fields(self, log_record, record, message_dict): 19 | subsecond, second = math.modf(record.created) 20 | log_record.update( 21 | { 22 | "timestamp": {"seconds": int(second), "nanos": int(subsecond * 1e9)}, 23 | "thread": record.thread, 24 | "severity": record.levelname, 25 | } 26 | ) 27 | super().add_fields(log_record, record, message_dict) 28 | -------------------------------------------------------------------------------- /mondrian/errors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | 5 | def _get_error_message(exc): 6 | if hasattr(exc, "__str__"): 7 | message = str(exc) 8 | return message[0].upper() + message[1:] 9 | return ("\n".join(exc.args),) 10 | 11 | 12 | def excepthook(exctype, exc, traceback, level=logging.CRITICAL, logger=None, context=None): 13 | """ 14 | Error handler. Whatever happens in a plugin or component, if it looks like an exception, taste like an exception 15 | or somehow make me think it is an exception, I'll handle it. 16 | 17 | :param exc: the culprit 18 | :param trace: Hercule Poirot's logbook. 19 | :return: to hell 20 | """ 21 | 22 | context = context or "thread {}".format(threading.get_ident()) 23 | return (logger or logging.getLogger()).log( 24 | level, 25 | "Uncaught exception{}.".format(" (in {})".format(context) if context else ""), 26 | exc_info=(exctype, exc, traceback), 27 | ) 28 | -------------------------------------------------------------------------------- /mondrian/filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from colorama import Fore 4 | 5 | from mondrian import levels 6 | 7 | 8 | class Filter(logging.Filter): 9 | def filter(self, record): 10 | record.color = "" 11 | record.spent = record.relativeCreated // 1000 12 | return True 13 | 14 | 15 | class ColorFilter(Filter): 16 | def filter(self, record): 17 | super().filter(record) 18 | record.color = levels.COLORS.get(record.levelname, Fore.LIGHTWHITE_EX) 19 | return True 20 | -------------------------------------------------------------------------------- /mondrian/formatters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import textwrap 4 | from traceback import format_exception as _format_exception 5 | 6 | from colorama import Style 7 | 8 | from mondrian import settings, styles 9 | from mondrian.term import CLEAR_EOL, iswindows, lightblack, lightblack_bg, lightwhite 10 | 11 | if iswindows or not settings.COLORS: 12 | EOL = "\n" 13 | else: 14 | EOL = Style.RESET_ALL + CLEAR_EOL + "\n" 15 | 16 | 17 | class _Style(logging.PercentStyle): 18 | def __init__(self): 19 | tokens = ("%(color)s%(levelname)s", "%(spent)04d", "%(name)s") 20 | 21 | sep = "|" if iswindows else (Style.RESET_ALL + lightblack(":")) 22 | self._fmt = sep.join(tokens) + lightblack(":") + " %(message)s" + EOL.strip() 23 | 24 | 25 | def format_exception(excinfo, *, prefix="", fg=lightblack, bg=lightblack_bg, summary=True): 26 | formatted_exception = _format_exception(*excinfo) 27 | 28 | output = [] 29 | stack_length = len(formatted_exception) 30 | for i, frame in enumerate(formatted_exception): 31 | _last = i + 1 == stack_length 32 | if frame.startswith(" "): 33 | output.append(textwrap.indent(" " + frame.strip(), fg("\u2502 "))) 34 | else: 35 | # XXX TODO change this to use valid python package regexp (plus dot). 36 | g = re.match("([a-zA-Z_.]+): (.*)$", frame.strip(), flags=re.DOTALL) 37 | if summary or not _last: 38 | if g is not None: 39 | etyp, emsg = g.group(1), g.group(2) 40 | output.append( 41 | fg(styles.BOTTOM_LEFT if _last else styles.VERT_LEFT) 42 | + bg(lightwhite(" " + etyp + " ")) 43 | + " " 44 | + lightwhite(textwrap.indent(str(emsg), " " * (len(etyp) + 4)).strip()) 45 | ) 46 | else: 47 | output.append(textwrap.indent(frame.strip(), fg("\u2502 "))) 48 | 49 | return textwrap.indent(EOL.join(output), prefix=prefix) 50 | 51 | 52 | class Formatter(logging.Formatter): 53 | def __init__(self): 54 | self._style = _Style() 55 | self._fmt = self._style._fmt 56 | 57 | formatException = staticmethod(format_exception) 58 | -------------------------------------------------------------------------------- /mondrian/humanizer.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | import sys 4 | from contextlib import contextmanager 5 | from sys import exc_info 6 | 7 | from mondrian import settings, term 8 | from mondrian.formatters import format_exception 9 | from mondrian.styles import BOTTOM_LEFT, BOTTOM_RIGHT, HORIZ, TOP_LEFT, TOP_RIGHT, VERT, VERT_LEFT 10 | 11 | preformatted_pattern = re.compile("([^`]*)`([^`]*)`([^`]*)") 12 | 13 | 14 | def humanized(exc, *, fg=term.red, bg=lambda *args: term.red_bg(term.bold(*args)), help_url=None): 15 | SPACES = 2 16 | prefix, suffix = fg(VERT + " " * (SPACES - 1)), fg(" " * (SPACES - 1) + VERT) 17 | result = [] 18 | 19 | def format_arg(arg): 20 | length = len(preformatted_pattern.sub("\\1\\2\\3", arg)) 21 | arg = preformatted_pattern.sub("\\1" + term.bold("\\2") + "\\3", arg) 22 | arg = re.sub("^ \$ (.*)", term.lightblack(" $ ") + term.reset("\\1"), arg) 23 | return (arg, length) 24 | 25 | def joined(*args): 26 | return "".join(args) 27 | 28 | term_width, term_height = term.get_size() 29 | line_length = min(80, term_width) 30 | for arg in exc.args: 31 | line_length = max(min(line_length, len(arg) + 2 * SPACES), 120) 32 | 33 | result.append(joined(fg(TOP_LEFT + HORIZ * (line_length - 2) + TOP_RIGHT))) 34 | 35 | args = list(exc.args) 36 | 37 | for i, arg in enumerate(args): 38 | 39 | if i == 1: 40 | result.append(joined(prefix, " " * (line_length - 2 * SPACES), suffix)) 41 | 42 | arg_formatted, arg_length = format_arg(arg) 43 | if not i: 44 | # first line 45 | result.append( 46 | joined( 47 | prefix, 48 | bg(" " + type(exc).__name__ + " "), 49 | " ", 50 | term.white(arg_formatted), 51 | " " * (line_length - (arg_length + 3 + len(type(exc).__name__) + 2 * SPACES)), 52 | suffix, 53 | ) 54 | ) 55 | else: 56 | # other lines 57 | result.append(joined(prefix, arg_formatted + " " * (line_length - arg_length - 2 * SPACES), suffix)) 58 | 59 | if help_url: 60 | help_prefix = "Read more: " 61 | arg_length = len(help_url) + len(help_prefix) 62 | arg_formatted = help_prefix + term.underline(term.lightblue(help_url)) 63 | result.append(joined(prefix, " " * (line_length - 2 * SPACES), suffix)) 64 | result.append(joined(prefix, arg_formatted + " " * (line_length - arg_length - 2 * SPACES), suffix)) 65 | 66 | more = settings.DEBUG 67 | exc_lines = format_exception(exc_info(), fg=fg, bg=bg, summary=False).splitlines() 68 | 69 | if not len(exc_lines): 70 | more = False 71 | 72 | result.append(joined(fg((VERT_LEFT if more else BOTTOM_LEFT) + HORIZ * (line_length - 2) + BOTTOM_RIGHT))) 73 | 74 | if more: 75 | for _line in exc_lines: 76 | result.append(_line) 77 | result.append(joined(fg("╵"))) 78 | elif len(exc_lines): 79 | result.append(term.lightblack("(add DEBUG=1 to system environment for stack trace)".rjust(line_length))) 80 | 81 | return "\n".join(result) 82 | 83 | 84 | class Success: 85 | def __init__(self, *args, help_url=None): 86 | self.args = args 87 | self.help_url = help_url 88 | 89 | def __str__(self): 90 | return humanized( 91 | self, fg=term.green, bg=(lambda *args: term.lightgreen_bg(term.lightblack(*args))), help_url=self.help_url 92 | ) 93 | 94 | 95 | @contextmanager 96 | def humanize(*, types=(Exception,)): 97 | """ 98 | Decorate a code block or a function to catch exceptions of `types` and displays it more gently to the user. One 99 | can always add DEBUG=1 to the env to show the full stack trace. 100 | 101 | Can be used both as a context manager: 102 | 103 | >>> with humanize(): 104 | ... ... 105 | 106 | and as a decorator: 107 | 108 | >>> @humanize() 109 | ... def foo(): 110 | ... ... 111 | 112 | :param types: tuple of exception types to humanize 113 | """ 114 | if not isinstance(types, tuple): 115 | types = (types,) 116 | 117 | try: 118 | yield 119 | except types as exc: 120 | print(humanized(exc), file=sys.stderr) 121 | raise 122 | -------------------------------------------------------------------------------- /mondrian/levels.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from colorama import Fore 4 | 5 | DEBUG = "DEBG" 6 | INFO = "INFO" 7 | WARNING = "WARN" 8 | ERROR = "ERR." 9 | CRITICAL = "CRIT" 10 | 11 | COLORS = { 12 | DEBUG: Fore.LIGHTCYAN_EX, 13 | INFO: Fore.LIGHTWHITE_EX, 14 | WARNING: Fore.LIGHTYELLOW_EX, 15 | ERROR: Fore.LIGHTRED_EX, 16 | CRITICAL: Fore.RED, 17 | } 18 | 19 | NAMES = { 20 | logging.DEBUG: DEBUG, 21 | logging.INFO: INFO, 22 | logging.WARNING: WARNING, 23 | logging.ERROR: ERROR, 24 | logging.CRITICAL: CRITICAL, 25 | } 26 | -------------------------------------------------------------------------------- /mondrian/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def to_bool(s, *, strict=True): 5 | if s is None: 6 | return None 7 | if isinstance(s, bool): 8 | return s 9 | if isinstance(s, str) and len(s): 10 | if strict: 11 | if s.lower() in ("f", "false", "n", "no", "0"): 12 | return False 13 | else: 14 | if s[0].lower() in ("f", "n", "0"): 15 | return False 16 | return True 17 | return False 18 | 19 | 20 | def get_bool_from_env(name, default): 21 | return to_bool(os.environ.get(name, default)) 22 | 23 | 24 | DEBUG = get_bool_from_env("DEBUG", False) 25 | COLORS = get_bool_from_env("COLORS", None) 26 | -------------------------------------------------------------------------------- /mondrian/styles.py: -------------------------------------------------------------------------------- 1 | # TOP_LEFT, TOP_RIGHT = '╔╗' 2 | # VERT, HORIZ = '║', '═' 3 | # BOTTOM_LEFT, BOTTOM_RIGHT = '╚╝' 4 | 5 | TOP_LEFT, TOP_RIGHT = "╭╮" 6 | VERT, VERT_LEFT, HORIZ = "│", "\u251c", "─" 7 | BOTTOM_LEFT, BOTTOM_RIGHT = "╰╯" 8 | -------------------------------------------------------------------------------- /mondrian/term.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shlex 4 | import struct 5 | import subprocess 6 | import sys 7 | 8 | from colorama.ansi import code_to_chars 9 | 10 | from mondrian import settings 11 | 12 | iswindows = sys.platform == "win32" 13 | 14 | 15 | def _is_interactive_console(): 16 | return sys.stdout.isatty() 17 | 18 | 19 | def _is_jupyter_notebook(): 20 | try: 21 | return get_ipython().__class__.__name__ == "ZMQInteractiveShell" 22 | except NameError: 23 | return False 24 | 25 | 26 | istty = _is_interactive_console() 27 | isjupyter = _is_jupyter_notebook() 28 | 29 | usecolors = istty and not iswindows 30 | 31 | if settings.COLORS is not None: 32 | usecolors = settings.COLORS 33 | 34 | 35 | def _create_color_wrappers(symbol): 36 | if usecolors: 37 | from colorama import Fore, Back 38 | 39 | fg, bg, rfg, rbg = getattr(Fore, symbol), getattr(Back, symbol), Fore.RESET, Back.RESET 40 | else: 41 | fg, bg, rfg, rbg = "", "", "", "" 42 | 43 | def fg_wrapper(*args): 44 | return "".join((fg, *args, rfg)) 45 | 46 | def bg_wrapper(*args): 47 | return "".join((bg, *args, rbg)) 48 | 49 | return fg_wrapper, bg_wrapper 50 | 51 | 52 | black, black_bg = _create_color_wrappers("BLACK") 53 | red, red_bg = _create_color_wrappers("RED") 54 | green, green_bg = _create_color_wrappers("GREEN") 55 | yellow, yellow_bg = _create_color_wrappers("YELLOW") 56 | blue, blue_bg = _create_color_wrappers("BLUE") 57 | magenta, magenta_bg = _create_color_wrappers("MAGENTA") 58 | cyan, cyan_bg = _create_color_wrappers("CYAN") 59 | white, white_bg = _create_color_wrappers("WHITE") 60 | reset, reset_bg = _create_color_wrappers("RESET") 61 | lightblack, lightblack_bg = _create_color_wrappers("LIGHTBLACK_EX") 62 | lightred, lightred_bg = _create_color_wrappers("LIGHTRED_EX") 63 | lightgreen, lightgreen_bg = _create_color_wrappers("LIGHTGREEN_EX") 64 | lightyellow, lightyellow_bg = _create_color_wrappers("LIGHTYELLOW_EX") 65 | lightblue, lightblue_bg = _create_color_wrappers("LIGHTBLUE_EX") 66 | lightmagenta, lightmagenta_bg = _create_color_wrappers("LIGHTMAGENTA_EX") 67 | lightcyan, lightcyan_bg = _create_color_wrappers("LIGHTCYAN_EX") 68 | lightwhite, lightwhite_bg = _create_color_wrappers("LIGHTWHITE_EX") 69 | 70 | if usecolors: 71 | 72 | def bold(*args): 73 | from colorama import Style 74 | 75 | return "".join((Style.BRIGHT, *args, Style.NORMAL)) 76 | 77 | def underline(*args): 78 | from colorama import Style 79 | 80 | return "".join((code_to_chars(4), *args, code_to_chars(0))) 81 | 82 | 83 | else: 84 | 85 | def bold(*args): 86 | return "".join(args) 87 | 88 | def underline(*args): 89 | return "".join(args) 90 | 91 | 92 | CLEAR_EOL = "\033[0K" 93 | 94 | 95 | def get_size(): 96 | """ getTerminalSize() 97 | - get width and height of console 98 | - works on linux,os x,windows,cygwin(windows) 99 | originally retrieved from: 100 | http://stackoverflow.com/questions/566746/how-to-get-console-window-width-in-python 101 | """ 102 | current_os = platform.system() 103 | tuple_xy = None 104 | if current_os == "Windows": 105 | tuple_xy = _get_size_windows() 106 | if tuple_xy is None: 107 | tuple_xy = _get_size_tput() 108 | # needed for window's python in cygwin's xterm! 109 | if current_os in ["Linux", "Darwin"] or current_os.startswith("CYGWIN"): 110 | tuple_xy = _get_size_linux() 111 | if tuple_xy is None: 112 | tuple_xy = (80, 25) # default value 113 | return tuple_xy 114 | 115 | 116 | def _get_size_windows(): 117 | try: 118 | from ctypes import windll, create_string_buffer 119 | 120 | # stdin handle is -10 121 | # stdout handle is -11 122 | # stderr handle is -12 123 | h = windll.kernel32.GetStdHandle(-12) 124 | csbi = create_string_buffer(22) 125 | res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) 126 | if res: 127 | (bufx, bufy, curx, cury, wattr, left, top, right, bottom, maxx, maxy) = struct.unpack( 128 | "hhhhHhhhhhh", csbi.raw 129 | ) 130 | sizex = right - left + 1 131 | sizey = bottom - top + 1 132 | return sizex, sizey 133 | except: 134 | pass 135 | 136 | 137 | def _get_size_tput(): 138 | # get terminal width 139 | # src: http://stackoverflow.com/questions/263890/how-do-i-find-the-width-height-of-a-terminal-window 140 | try: 141 | cols = int(subprocess.check_call(shlex.split("tput cols"))) 142 | rows = int(subprocess.check_call(shlex.split("tput lines"))) 143 | return (cols, rows) 144 | except: 145 | pass 146 | 147 | 148 | def _get_size_linux(): 149 | def ioctl_GWINSZ(fd): 150 | try: 151 | import fcntl 152 | import termios 153 | 154 | cr = struct.unpack("hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")) 155 | return cr 156 | except: 157 | pass 158 | 159 | cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2) 160 | if not cr: 161 | try: 162 | fd = os.open(os.ctermid(), os.O_RDONLY) 163 | cr = ioctl_GWINSZ(fd) 164 | os.close(fd) 165 | except: 166 | pass 167 | if not cr: 168 | try: 169 | cr = (os.environ["LINES"], os.environ["COLUMNS"]) 170 | except: 171 | return None 172 | return int(cr[1]), int(cr[0]) 173 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] 2 | -r requirements.txt 3 | alabaster==0.7.12 4 | appdirs==1.4.4 5 | atomicwrites==1.4.0 6 | attrs==20.3.0 7 | babel==2.9.0 8 | certifi==2020.11.8 9 | cfgv==3.2.0 10 | chardet==3.0.4 11 | coverage==4.5.4 12 | distlib==0.3.1 13 | docutils==0.16 14 | filelock==3.0.12 15 | identify==1.5.10 16 | idna==2.10 17 | imagesize==1.2.0 18 | importlib-metadata==3.1.0 19 | isort==5.6.4 20 | jinja2==2.11.2 21 | markupsafe==1.1.1 22 | more-itertools==8.6.0 23 | nodeenv==1.5.0 24 | packaging==20.4 25 | pluggy==0.13.1 26 | pre-commit==2.9.2 27 | py==1.9.0 28 | pygments==2.7.2 29 | pyparsing==2.4.7 30 | pytest-cov==2.10.1 31 | pytest==4.6.11 32 | pytz==2020.4 33 | pyyaml==5.3.1 34 | requests==2.25.0 35 | six==1.15.0 36 | snowballstemmer==2.0.0 37 | sphinx==1.8.5 38 | sphinxcontrib-serializinghtml==1.1.4 39 | sphinxcontrib-websupport==1.2.4 40 | toml==0.10.2 41 | urllib3==1.26.2 42 | virtualenv==20.2.1 43 | wcwidth==0.2.5 44 | zipp==3.4.0 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | colorama==0.4.4 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | description-file = README.rst 6 | 7 | [isort] 8 | line_length = 120 9 | 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Generated by Medikit 0.8.0 on 2020-11-27. 2 | # All changes will be overriden. 3 | # Edit Projectfile and run “make update” (or “medikit update”) to regenerate. 4 | 5 | from codecs import open 6 | from os import path 7 | 8 | from setuptools import find_packages, setup 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | # Py3 compatibility hacks, borrowed from IPython. 13 | try: 14 | execfile 15 | except NameError: 16 | 17 | def execfile(fname, globs, locs=None): 18 | locs = locs or globs 19 | exec(compile(open(fname).read(), fname, "exec"), globs, locs) 20 | 21 | 22 | # Get the long description from the README file 23 | try: 24 | with open(path.join(here, "README.rst"), encoding="utf-8") as f: 25 | long_description = f.read() 26 | except: 27 | long_description = "" 28 | 29 | # Get the classifiers from the classifiers file 30 | tolines = lambda c: list(filter(None, map(lambda s: s.strip(), c.split("\n")))) 31 | try: 32 | with open(path.join(here, "classifiers.txt"), encoding="utf-8") as f: 33 | classifiers = tolines(f.read()) 34 | except: 35 | classifiers = [] 36 | 37 | version_ns = {} 38 | try: 39 | execfile(path.join(here, "mondrian/_version.py"), version_ns) 40 | except EnvironmentError: 41 | version = "dev" 42 | else: 43 | version = version_ns.get("__version__", "dev") 44 | 45 | setup( 46 | author="Romain Dorgueil", 47 | author_email="romain@dorgueil.net", 48 | description=( 49 | "Mondrian helps you to configure and use python's logging module once and for " 50 | "ever." 51 | ), 52 | license="Apache License, Version 2.0", 53 | name="mondrian", 54 | version=version, 55 | long_description=long_description, 56 | classifiers=classifiers, 57 | packages=find_packages(exclude=["ez_setup", "example", "test"]), 58 | include_package_data=True, 59 | install_requires=["colorama >= 0.3.7, < 0.5"], 60 | extras_require={ 61 | "dev": [ 62 | "coverage ~= 4.5", 63 | "isort", 64 | "pre-commit ~= 2.9.2", 65 | "pytest ~= 4.6", 66 | "pytest-cov ~= 2.7", 67 | "sphinx ~= 1.7", 68 | ] 69 | }, 70 | url="https://python-mondrian.github.io/", 71 | download_url="https://github.com/python-mondrian/mondrian/archive/{version}.tar.gz".format( 72 | version=version 73 | ), 74 | ) 75 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-mondrian/mondrian/15ea7b9d13e4207e072209e19b3536371b79df46/tests/.gitkeep --------------------------------------------------------------------------------