diff --git a/.appveyor/after_test.bat b/.appveyor/after_test.bat index 79efa01..c3ea121 100644 --- a/.appveyor/after_test.bat +++ b/.appveyor/after_test.bat @@ -1,5 +1,5 @@ IF DEFINED CYBUILD ( - %WITH_COMPILER% python setup.py bdist_wheel + %BUILD% python setup.py bdist_wheel IF "%APPVEYOR_REPO_TAG%"=="true" ( twine upload -u %PYPI_USERNAME% -p %PYPI_PASSWORD% dist\*.whl ) diff --git a/.appveyor/build.cmd b/.appveyor/build.cmd new file mode 100644 index 0000000..75ac073 --- /dev/null +++ b/.appveyor/build.cmd @@ -0,0 +1,21 @@ +@echo off +:: To build extensions for 64 bit Python 3, we need to configure environment +:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 4 +:: +:: More details at: +:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows + +IF "%DISTUTILS_USE_SDK%"=="1" ( + ECHO Configuring environment to build with MSVC on a 64bit architecture + ECHO Using Windows SDK 7.1 + "C:\Program Files\Microsoft SDKs\Windows\v7.1\Setup\WindowsSdkVer.exe" -q -version:v7.1 + CALL "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd" /x64 /release + SET MSSdk=1 + REM Need the following to allow tox to see the SDK compiler + SET TOX_TESTENV_PASSENV=DISTUTILS_USE_SDK MSSdk INCLUDE LIB +) ELSE ( + ECHO Using default MSVC build environment +) + +CALL %* \ No newline at end of file diff --git a/.appveyor/install.ps1 b/.appveyor/install.ps1 deleted file mode 100644 index 94d6f01..0000000 --- a/.appveyor/install.ps1 +++ /dev/null @@ -1,229 +0,0 @@ -# Sample script to install Python and pip under Windows -# Authors: Olivier Grisel, Jonathan Helmus, Kyle Kastner, and Alex Willmer -# License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ - -$MINICONDA_URL = "http://repo.continuum.io/miniconda/" -$BASE_URL = "https://www.python.org/ftp/python/" -$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" -$GET_PIP_PATH = "C:\get-pip.py" - -$PYTHON_PRERELEASE_REGEX = @" -(?x) -(?\d+) -\. -(?\d+) -\. -(?\d+) -(?[a-z]{1,2}\d+) -"@ - - -function Download ($filename, $url) { - $webclient = New-Object System.Net.WebClient - - $basedir = $pwd.Path + "\" - $filepath = $basedir + $filename - if (Test-Path $filename) { - Write-Host "Reusing" $filepath - return $filepath - } - - # Download and retry up to 3 times in case of network transient errors. - Write-Host "Downloading" $filename "from" $url - $retry_attempts = 2 - for ($i = 0; $i -lt $retry_attempts; $i++) { - try { - $webclient.DownloadFile($url, $filepath) - break - } - Catch [Exception]{ - Start-Sleep 1 - } - } - if (Test-Path $filepath) { - Write-Host "File saved at" $filepath - } else { - # Retry once to get the error message if any at the last try - $webclient.DownloadFile($url, $filepath) - } - return $filepath -} - - -function ParsePythonVersion ($python_version) { - if ($python_version -match $PYTHON_PRERELEASE_REGEX) { - return ([int]$matches.major, [int]$matches.minor, [int]$matches.micro, - $matches.prerelease) - } - $version_obj = [version]$python_version - return ($version_obj.major, $version_obj.minor, $version_obj.build, "") -} - - -function DownloadPython ($python_version, $platform_suffix) { - $major, $minor, $micro, $prerelease = ParsePythonVersion $python_version - - if (($major -le 2 -and $micro -eq 0) ` - -or ($major -eq 3 -and $minor -le 2 -and $micro -eq 0) ` - ) { - $dir = "$major.$minor" - $python_version = "$major.$minor$prerelease" - } else { - $dir = "$major.$minor.$micro" - } - - if ($prerelease) { - if (($major -le 2) ` - -or ($major -eq 3 -and $minor -eq 1) ` - -or ($major -eq 3 -and $minor -eq 2) ` - -or ($major -eq 3 -and $minor -eq 3) ` - ) { - $dir = "$dir/prev" - } - } - - if (($major -le 2) -or ($major -le 3 -and $minor -le 4)) { - $ext = "msi" - if ($platform_suffix) { - $platform_suffix = ".$platform_suffix" - } - } else { - $ext = "exe" - if ($platform_suffix) { - $platform_suffix = "-$platform_suffix" - } - } - - $filename = "python-$python_version$platform_suffix.$ext" - $url = "$BASE_URL$dir/$filename" - $filepath = Download $filename $url - return $filepath -} - - -function InstallPython ($python_version, $architecture, $python_home) { - Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home - if (Test-Path $python_home) { - Write-Host $python_home "already exists, skipping." - return $false - } - if ($architecture -eq "32") { - $platform_suffix = "" - } else { - $platform_suffix = "amd64" - } - $installer_path = DownloadPython $python_version $platform_suffix - $installer_ext = [System.IO.Path]::GetExtension($installer_path) - Write-Host "Installing $installer_path to $python_home" - $install_log = $python_home + ".log" - if ($installer_ext -eq '.msi') { - InstallPythonMSI $installer_path $python_home $install_log - } else { - InstallPythonEXE $installer_path $python_home $install_log - } - if (Test-Path $python_home) { - Write-Host "Python $python_version ($architecture) installation complete" - } else { - Write-Host "Failed to install Python in $python_home" - Get-Content -Path $install_log - Exit 1 - } -} - - -function InstallPythonEXE ($exepath, $python_home, $install_log) { - $install_args = "/quiet InstallAllUsers=1 TargetDir=$python_home" - RunCommand $exepath $install_args -} - - -function InstallPythonMSI ($msipath, $python_home, $install_log) { - $install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home" - $uninstall_args = "/qn /x $msipath" - RunCommand "msiexec.exe" $install_args - if (-not(Test-Path $python_home)) { - Write-Host "Python seems to be installed else-where, reinstalling." - RunCommand "msiexec.exe" $uninstall_args - RunCommand "msiexec.exe" $install_args - } -} - -function RunCommand ($command, $command_args) { - Write-Host $command $command_args - Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru -} - - -function InstallPip ($python_home) { - $pip_path = $python_home + "\Scripts\pip.exe" - $python_path = $python_home + "\python.exe" - if (-not(Test-Path $pip_path)) { - Write-Host "Installing pip..." - $webclient = New-Object System.Net.WebClient - $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) - Write-Host "Executing:" $python_path $GET_PIP_PATH - & $python_path $GET_PIP_PATH - } else { - Write-Host "pip already installed." - } -} - - -function DownloadMiniconda ($python_version, $platform_suffix) { - if ($python_version -eq "3.4") { - $filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe" - } else { - $filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe" - } - $url = $MINICONDA_URL + $filename - $filepath = Download $filename $url - return $filepath -} - - -function InstallMiniconda ($python_version, $architecture, $python_home) { - Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home - if (Test-Path $python_home) { - Write-Host $python_home "already exists, skipping." - return $false - } - if ($architecture -eq "32") { - $platform_suffix = "x86" - } else { - $platform_suffix = "x86_64" - } - $filepath = DownloadMiniconda $python_version $platform_suffix - Write-Host "Installing" $filepath "to" $python_home - $install_log = $python_home + ".log" - $args = "/S /D=$python_home" - Write-Host $filepath $args - Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru - if (Test-Path $python_home) { - Write-Host "Python $python_version ($architecture) installation complete" - } else { - Write-Host "Failed to install Python in $python_home" - Get-Content -Path $install_log - Exit 1 - } -} - - -function InstallMinicondaPip ($python_home) { - $pip_path = $python_home + "\Scripts\pip.exe" - $conda_path = $python_home + "\Scripts\conda.exe" - if (-not(Test-Path $pip_path)) { - Write-Host "Installing pip..." - $args = "install --yes pip" - Write-Host $conda_path $args - Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru - } else { - Write-Host "pip already installed." - } -} - -function main () { - InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON - InstallPip $env:PYTHON -} - -main \ No newline at end of file diff --git a/.appveyor/prepare.bat b/.appveyor/prepare.bat index be1491f..1fcec18 100644 --- a/.appveyor/prepare.bat +++ b/.appveyor/prepare.bat @@ -1,13 +1,24 @@ -pip install wheel -nuget install redis-64 -excludeversion -redis-64\redis-server.exe --service-install -redis-64\redis-server.exe --service-start -nuget install ZeroMQ -%WITH_COMPILER% pip install cython pyzmq -python scripts\test_setup.py -python setup.py develop +pip install -U wheel setuptools || goto :error +nuget install redis-64 -excludeversion || goto :error +redis-64\tools\redis-server.exe --service-install || goto :error +redis-64\tools\redis-server.exe --service-start || goto :error +IF NOT DEFINED SKIPZMQ ( + nuget install ZeroMQ || goto :error +) IF DEFINED CYBUILD ( - cython logbook\_speedups.pyx - %WITH_COMPILER% python setup.py build - pip install twine + %BUILD% pip install cython twine || goto :error + cython logbook\_speedups.pyx || goto :error +) ELSE ( + set DISABLE_LOGBOOK_CEXT=True ) +IF DEFINED SKIPZMQ ( + %BUILD% pip install -e .[dev,execnet,jinja,sqlalchemy,redis] || goto :error +) ELSE ( + %BUILD% pip install -e .[all] || goto :error +) +REM pypiwin32 can fail, ignore error. +%BUILD% pip install pypiwin32 +exit /b 0 + +:error +exit /b %errorlevel% diff --git a/.appveyor/run_with_compiler.cmd b/.appveyor/run_with_compiler.cmd deleted file mode 100644 index d549afe..0000000 --- a/.appveyor/run_with_compiler.cmd +++ /dev/null @@ -1,88 +0,0 @@ -:: To build extensions for 64 bit Python 3, we need to configure environment -:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) -:: -:: To build extensions for 64 bit Python 2, we need to configure environment -:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) -:: -:: 32 bit builds, and 64-bit builds for 3.5 and beyond, do not require specific -:: environment configurations. -:: -:: Note: this script needs to be run with the /E:ON and /V:ON flags for the -:: cmd interpreter, at least for (SDK v7.0) -:: -:: More details at: -:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows -:: http://stackoverflow.com/a/13751649/163740 -:: -:: Author: Olivier Grisel -:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ -:: -:: Notes about batch files for Python people: -:: -:: Quotes in values are literally part of the values: -:: SET FOO="bar" -:: FOO is now five characters long: " b a r " -:: If you don't want quotes, don't include them on the right-hand side. -:: -:: The CALL lines at the end of this file look redundant, but if you move them -:: outside of the IF clauses, they do not run properly in the SET_SDK_64==Y -:: case, I don't know why. -@ECHO OFF - -SET COMMAND_TO_RUN=%* -SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows -SET WIN_WDK=c:\Program Files (x86)\Windows Kits\10\Include\wdf - -:: Extract the major and minor versions, and allow for the minor version to be -:: more than 9. This requires the version number to have two dots in it. -SET MAJOR_PYTHON_VERSION=%PYTHON_VERSION:~0,1% -IF "%PYTHON_VERSION:~3,1%" == "." ( - SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,1% -) ELSE ( - SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,2% -) - -:: Based on the Python version, determine what SDK version to use, and whether -:: to set the SDK for 64-bit. -IF %MAJOR_PYTHON_VERSION% == 2 ( - SET WINDOWS_SDK_VERSION="v7.0" - SET SET_SDK_64=Y -) ELSE ( - IF %MAJOR_PYTHON_VERSION% == 3 ( - SET WINDOWS_SDK_VERSION="v7.1" - IF %MINOR_PYTHON_VERSION% LEQ 4 ( - SET SET_SDK_64=Y - ) ELSE ( - SET SET_SDK_64=N - IF EXIST "%WIN_WDK%" ( - :: See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ - REN "%WIN_WDK%" 0wdf - ) - ) - ) ELSE ( - ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" - EXIT 1 - ) -) - -IF %PYTHON_ARCH% == 64 ( - IF %SET_SDK_64% == Y ( - ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture - SET DISTUTILS_USE_SDK=1 - SET MSSdk=1 - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 - ) ELSE ( - ECHO Using default MSVC build environment for 64 bit architecture - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 - ) -) ELSE ( - ECHO Using default MSVC build environment for 32 bit architecture - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 -) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 11732d0..35a034f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,66 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Logbook specific / custom ignores .ropeproject -.tox -docs/_build logbook/_speedups.c -logbook/_speedups.so -Logbook.egg-info -dist -*.pyc -env env* -.coverage -cover -build .vagrant flycheck-* -.cache diff --git a/.travis.yml b/.travis.yml index 43efd39..ef885f0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,33 +2,34 @@ services: - redis-server python: -- '2.6' - '2.7' -- '3.2' -- '3.3' -- '3.4' - '3.5' +- '3.6' - pypy -- pypy3 +before_install: + - pip install coveralls install: - sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm - sudo apt-add-repository -y ppa:chris-lea/zeromq - sudo apt-get update - sudo apt-get install -y libzmq3-dev -- pip install cython redis -- easy_install pyzmq -- make test_setup -- python setup.py develop +- pip install -U pip +- pip install cython +- cython logbook/_speedups.pyx env: -- COMMAND="make test" -- COMMAND="make cybuild test" -script: $COMMAND +- DISABLE_LOGBOOK_CEXT=True +- CYBUILD=True +script: +- pip install -e .[all] +- py.test --cov=logbook -r s tests matrix: exclude: - python: pypy - env: COMMAND="make cybuild test" + env: CYBUILD=True - python: pypy3 - env: COMMAND="make cybuild test" + env: CYBUILD=True +after_success: + - coveralls notifications: email: recipients: @@ -40,14 +41,12 @@ on_failure: always use_notice: true skip_join: true -before_deploy: - - make logbook/_speedups.so deploy: - provider: pypi - user: vmalloc - password: - secure: WFmuAbtBDIkeZArIFQRCwyO1TdvF2PaZpo75r3mFgnY+aWm75cdgjZKoNqVprF/f+v9EsX2kDdQ7ZfuhMLgP8MNziB+ty7579ZDGwh64jGoi+DIoeblAFu5xNAqjvhie540uCE8KySk9s+Pq5EpOA5w18V4zxTw+h6tnBQ0M9cQ= - on: - tags: true - repo: getlogbook/logbook - distributions: "sdist bdist_egg" + - provider: pypi + user: vmalloc + password: + secure: WFmuAbtBDIkeZArIFQRCwyO1TdvF2PaZpo75r3mFgnY+aWm75cdgjZKoNqVprF/f+v9EsX2kDdQ7ZfuhMLgP8MNziB+ty7579ZDGwh64jGoi+DIoeblAFu5xNAqjvhie540uCE8KySk9s+Pq5EpOA5w18V4zxTw+h6tnBQ0M9cQ= + on: + tags: true + repo: getlogbook/logbook + distributions: "sdist" diff --git a/CHANGES b/CHANGES index 9f75b26..11781b1 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,55 @@ Here you can see the full list of changes between each Logbook release. +Version 1.3.0 +------------- + +Released on March 5th, 2018 + +- Added support for controlling rotating file names -- Logbook now allows users to customize the formatting of rollover/rotating files (thanks Tucker Beck) + +Version 1.2.0 +------------- + +Released on February 8th, 2018 + +- Added support for compressed log files, supporting both gzip and brotli compression methods (thanks Maor Marcus) +- Fixed CPU usage for queuing handlers (thanks Adam Urbańczyk) + + +Version 1.1.0 +------------- + +Released on July 13th 2017 + +- Added a handler for Riemann (thanks Šarūnas Navickas) +- Added a handler for Slack (thanks @jonathanng) +- Colorizing mixin can now force coloring on or off (thanks @ayalash) + + +Version 1.0.1 +------------- + +- Fix PushOver handler cropping (thanks Sébastien Celles) + + +VERSION 1.0.0 +------------- + +Released on June 26th 2016 + +- Added support for timezones for log timestamp formatting (thanks Mattijs Ugen) +- Logbook has been a 0.x long enough to earn its 1.0.0 bump! +- Logbook now uses SemVer for its versioning scheme +- Various improvements to MailHandler and the usage of TLS/SMTP SSL (thanks Frazer McLean) +- Fix log colorizing on Windows (thanks Frazer McLean) +- Coverage reports using coveralls.io +- Dropped compatibility for Python 3.2. At this point we did not actually remove any code that supports it, but the continuous integration tests no longer check against it, and we will no longer fix compatibility issues with 3.2. +- Better coverage and tests on Windows (thanks Frazer McLean) +- Added enable() and disable() methods for loggers (thanks Frazer McLean) +- Many cleanups and overall project improvements (thanks Frazer McLean) + + Version 0.12.0 -------------- @@ -10,7 +59,7 @@ - Added logbook.utils.deprecated to automatically emit warnings when certain functions are called (Thanks Ayala Shachar) - Added logbook.utils.suppressed_deprecations context to temporarily suppress deprecations (Thanks Ayala Shachar) -- Added logbook.utils.logged_if_slow_context to emit logs when certain operations exceed a time threshold (Thanks Ayala Shachar) +- Added logbook.utils.logged_if_slow to emit logs when certain operations exceed a time threshold (Thanks Ayala Shachar) - Many PEP8 fixes and code cleanups (thanks Taranjeet Singh and Frazer McLean) - TestHandler constructor now receives an optional `force_heavy_init=True`, forcing all records to heavy-initialize diff --git a/MANIFEST.in b/MANIFEST.in index 8267349..85dd8a3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ -include MANIFEST.in Makefile CHANGES logbook/_speedups.c logbook/_speedups.pyx tox.ini +include MANIFEST.in Makefile CHANGES logbook/_speedups.c logbook/_speedups.pyx tox.ini LICENSE include scripts/test_setup.py recursive-include tests * - diff --git a/Makefile b/Makefile index d5b503f..bdf6f43 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,7 @@ logbook/_speedups.so: logbook/_speedups.pyx cython logbook/_speedups.pyx - python setup.py build - cp build/*/logbook/_speedups*.so logbook + python setup.py build_ext --inplace cybuild: logbook/_speedups.so diff --git a/README.md b/README.md index a241711..6d22351 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # Welcome to Logbook + + + + | | | |--------------------|-----------------------------| | Travis | [![Build Status][ti]][tl] | | AppVeyor | [![Build Status][ai]][al] | | Supported Versions | ![Supported Versions][vi] | -| Downloads | ![Downloads][di] | | Latest Version | [![Latest Version][pi]][pl] | +| Test Coverage | [![Test Coverage][ci]][cl] | Logbook is a nice logging replacement. @@ -18,8 +22,10 @@ [ti]: https://secure.travis-ci.org/getlogbook/logbook.svg?branch=master [tl]: https://travis-ci.org/getlogbook/logbook [ai]: https://ci.appveyor.com/api/projects/status/quu99exa26e06npp?svg=true -[vi]: https://img.shields.io/pypi/pyversions/logbook.svg +[vi]: https://img.shields.io/badge/python-2.6%2C2.7%2C3.3%2C3.4%2C3.5-green.svg [di]: https://img.shields.io/pypi/dm/logbook.svg [al]: https://ci.appveyor.com/project/vmalloc/logbook [pi]: https://img.shields.io/pypi/v/logbook.svg [pl]: https://pypi.python.org/pypi/Logbook +[ci]: https://coveralls.io/repos/getlogbook/logbook/badge.svg?branch=master&service=github +[cl]: https://coveralls.io/github/getlogbook/logbook?branch=master diff --git a/appveyor.yml b/appveyor.yml index c50b6b6..6f11600 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -6,143 +6,45 @@ # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the # /E:ON and /V:ON options are not enabled in the batch script intepreter # See: http://stackoverflow.com/a/13751649/163740 - WITH_COMPILER: "cmd /E:ON /V:ON /C .\\.appveyor\\run_with_compiler.cmd" + BUILD: "cmd /E:ON /V:ON /C .\\.appveyor\\build.cmd" PYPI_USERNAME: secure: ixvjwUN/HsSfGkU3OvtQ8Q== PYPI_PASSWORD: secure: KOr+oEHZJmo1el3bT+ivmQ== + ENABLE_LOGBOOK_NTEVENTLOG_TESTS: "TRUE" matrix: - # Python 2.6.6 is the latest Python 2.6 with a Windows installer - # See: https://github.com/ogrisel/python-appveyor-demo/issues/10 - - - PYTHON: "C:\\Python266" - PYTHON_VERSION: "2.6.6" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python266" - PYTHON_VERSION: "2.6.6" - PYTHON_ARCH: "32" - CYBUILD: "TRUE" - - - PYTHON: "C:\\Python266-x64" - PYTHON_VERSION: "2.6.6" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python266-x64" - PYTHON_VERSION: "2.6.6" - PYTHON_ARCH: "64" - CYBUILD: "TRUE" - - # Pre-installed Python versions, which Appveyor may upgrade to - # a later point release. - # See: http://www.appveyor.com/docs/installed-software#python - PYTHON: "C:\\Python27" - PYTHON_VERSION: "2.7.x" - PYTHON_ARCH: "32" - - PYTHON: "C:\\Python27" - PYTHON_VERSION: "2.7.x" - PYTHON_ARCH: "32" CYBUILD: "TRUE" - PYTHON: "C:\\Python27-x64" - PYTHON_VERSION: "2.7.x" - PYTHON_ARCH: "64" - - PYTHON: "C:\\Python27-x64" - PYTHON_VERSION: "2.7.x" - PYTHON_ARCH: "64" - CYBUILD: "TRUE" - - # Python 3.2 isn't preinstalled - - - PYTHON: "C:\\Python325" - PYTHON_VERSION: "3.2.5" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python325" - PYTHON_VERSION: "3.2.5" - PYTHON_ARCH: "32" - CYBUILD: "TRUE" - - - PYTHON: "C:\\Python325-x64" - PYTHON_VERSION: "3.2.5" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python325-x64" - PYTHON_VERSION: "3.2.5" - PYTHON_ARCH: "64" - CYBUILD: "TRUE" - - # Pre-installed Python versions, which Appveyor may upgrade to - # a later point release. - # See: http://www.appveyor.com/docs/installed-software#python - - - PYTHON: "C:\\Python33" - PYTHON_VERSION: "3.3.x" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python33" - PYTHON_VERSION: "3.3.x" - PYTHON_ARCH: "32" - CYBUILD: "TRUE" - - - PYTHON: "C:\\Python33-x64" - PYTHON_VERSION: "3.3.x" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python33-x64" - PYTHON_VERSION: "3.3.x" - PYTHON_ARCH: "64" - CYBUILD: "TRUE" - - - PYTHON: "C:\\Python34" - PYTHON_VERSION: "3.4.x" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python34" - PYTHON_VERSION: "3.4.x" - PYTHON_ARCH: "32" - CYBUILD: "TRUE" - - - PYTHON: "C:\\Python34-x64" - PYTHON_VERSION: "3.4.x" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python34-x64" - PYTHON_VERSION: "3.4.x" - PYTHON_ARCH: "64" CYBUILD: "TRUE" - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "32" - - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "32" CYBUILD: "TRUE" - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "64" - - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "64" CYBUILD: "TRUE" + - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python36" + CYBUILD: "TRUE" + + - PYTHON: "C:\\Python36-x64" + - PYTHON: "C:\\Python36-x64" + CYBUILD: "TRUE" init: - - echo %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH% + - echo %PYTHON% - set PATH=%PYTHON%;%PYTHON%\Scripts;%PATH% install: - - powershell .appveyor\\install.ps1 - ".appveyor\\prepare.bat" - - ps: if (Test-Path Env:\CYBUILD) {Copy-Item build\*\logbook\*.pyd logbook\} build: off @@ -154,7 +56,7 @@ artifacts: # Archive the generated packages in the ci.appveyor.com build report. - - path: dist\* + - path: dist\*.whl deploy: description: '' diff --git a/docs/_static/logbook-logo.png b/docs/_static/logbook-logo.png new file mode 100644 index 0000000..48d5e15 Binary files /dev/null and b/docs/_static/logbook-logo.png differ diff --git a/docs/api/more.rst b/docs/api/more.rst index ca91612..738b995 100644 --- a/docs/api/more.rst +++ b/docs/api/more.rst @@ -24,6 +24,9 @@ .. autoclass:: TwitterHandler :members: +.. autoclass:: SlackHandler + :members: + .. autoclass:: ExternalApplicationHandler :members: diff --git a/docs/api/utilities.rst b/docs/api/utilities.rst index 17ae09b..22d35e3 100644 --- a/docs/api/utilities.rst +++ b/docs/api/utilities.rst @@ -34,7 +34,7 @@ ----------------------- .. module:: logbook.utils -.. autofunction:: logged_if_slow_context +.. autofunction:: logged_if_slow Deprecations diff --git a/docs/conf.py b/docs/conf.py index 77530e5..aa90731 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -125,7 +125,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] +html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -160,7 +160,7 @@ # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True -html_add_permalinks = False +# html_add_permalinks = '' # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the diff --git a/docs/cookbook.rst b/docs/cookbook.rst new file mode 100644 index 0000000..51ac28e --- /dev/null +++ b/docs/cookbook.rst @@ -0,0 +1,20 @@ +Cookbook +======== + +Filtering Records Based on Extra Info +------------------------------------- + +.. code-block:: python + + # This code demonstrates the usage of the `extra` argument for log records to enable advanced filtering of records through handlers + + import logbook + + if __name__ == "__main__": + + only_interesting = logbook.FileHandler('/tmp/interesting.log', filter=lambda r, h: r.extra['interesting']) + everything = logbook.FileHandler('/tmp/all.log', bubble=True) + + with only_interesting, everything: + logbook.info('this is interesting', extra={'interesting': True}) + logbook.info('this is not interesting') diff --git a/docs/designdefense.rst b/docs/designdefense.rst index 40ec318..e2d99f2 100644 --- a/docs/designdefense.rst +++ b/docs/designdefense.rst @@ -106,7 +106,7 @@ system like Logbook has, there is a lot more you can do. For example you could immediately after your application boots up -instanciate a :class:`~logbook.FingersCrossedHandler`. This handler +instantiate a :class:`~logbook.FingersCrossedHandler`. This handler buffers *all* log records in memory and does not emit them at all. What's the point? That handler activates when a certain threshold is reached. For example, when the first warning occurs you can write the buffered @@ -213,7 +213,7 @@ The last pillar of logbook's design is the compatibility with the standard libraries logging system. There are many libraries that exist currently that log information with the standard libraries logging module. Having -two separate logging systems in the same process is countrproductive and +two separate logging systems in the same process is counterproductive and will cause separate logfiles to appear in the best case or complete chaos in the worst. diff --git a/docs/features.rst b/docs/features.rst index d44ab1a..df9734c 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -7,8 +7,8 @@ We think it will work out for you and be fun to use :) Logbook leverages some features of Python that are not available in older Python releases. -Logbook currently requires Python 2.7 or higher including Python 3 (3.1 or -higher, 3.0 is not supported). +Logbook currently requires Python 2.7 or higher including Python 3 (3.3 or +higher, 3.2 and lower is not supported). Core Features ------------- diff --git a/docs/index.rst b/docs/index.rst index 70a6081..44e8dad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,7 @@ Feedback is appreciated. The docs here only show a tiny, tiny feature set and can be incomplete. We will have better docs -soon, but until then we hope this gives a sneak peak about how cool +soon, but until then we hope this gives a sneak peek about how cool Logbook is. If you want more, have a look at the comprehensive suite of tests. Documentation @@ -38,6 +38,7 @@ api/index designexplained designdefense + cookbook changelog Project Information diff --git a/docs/libraries.rst b/docs/libraries.rst index 8e49f94..aa4335e 100644 --- a/docs/libraries.rst +++ b/docs/libraries.rst @@ -52,6 +52,104 @@ stack in a function and not reverting it at the end of the function is bad. +Example Setup +------------- + +Consider how your logger should be configured by default. Users familiar with +:mod:`logging` from the standard library probably expect your logger to be +disabled by default:: + + import yourmodule + import logbook + + yourmodule.logger.enable() + + def main(): + ... + yourmodule.something() + ... + + if __name__ == '__main__': + with logbook.StderrHandler(): + main() + +or set to a high level (e.g. `WARNING`) by default, allowing them to opt in to +more detail if desired:: + + import yourmodule + import logbook + + yourmodule.logger.level = logbook.WARNING + + def main(): + ... + yourmodule.something() + ... + + if __name__ == '__main__': + with logbook.StderrHandler(): + main() + +Either way, make sure to document how your users can enable your logger, +including basic use of logbook handlers. Some users may want to continue using +:mod:`logging`, so you may want to link to +:class:`~logbook.compat.LoggingHandler`. + +Multiple Logger Example Setup +----------------------------- + +You may want to use multiple loggers in your library. It may be worthwhile to +add a logger group to allow the level or disabled attributes of all your +loggers to be set at once. + +For example, your library might look something like this: + +.. code-block:: python + :caption: yourmodule/__init__.py + + from .log import logger_group + +.. code-block:: python + :caption: yourmodule/log.py + + import logbook + + logger_group = logbook.LoggerGroup() + logger_group.level = logbook.WARNING + +.. code-block:: python + :caption: yourmodule/engine.py + + from logbook import Logger + from .log import logger_group + + logger = Logger('yourmodule.engine') + logger_group.add_logger(logger) + +.. code-block:: python + :caption: yourmodule/parser.py + + from logbook import Logger + from .log import logger_group + + logger = Logger('yourmodule.parser') + logger_group.add_logger(logger) + +The library user can then choose what level of logging they would like from +your library:: + + import logbook + import yourmodule + + yourmodule.logger_group.level = logbook.INFO + +They might only want to see debug messages from one of the loggers:: + + import logbook + import yourmodule + + yourmodule.engine.logger.level = logbook.DEBUG + Debug Loggers ------------- diff --git a/docs/performance.rst b/docs/performance.rst index dfc4d65..fc9d75e 100644 --- a/docs/performance.rst +++ b/docs/performance.rst @@ -24,7 +24,7 @@ This is where the Python ``__debug__`` feature comes in handy. This variable is a special flag that is evaluated at the time where Python -processes your script. It can elliminate code completely from your script +processes your script. It can eliminate code completely from your script so that it does not even exist in the compiled bytecode (requires Python to be run with the ``-O`` switch):: diff --git a/docs/setups.rst b/docs/setups.rst index d695b6b..591ffb9 100644 --- a/docs/setups.rst +++ b/docs/setups.rst @@ -112,7 +112,7 @@ # make sure we never bubble up to the stderr handler # if we run out of setup handling NullHandler(), - # then write messages that are at least warnings to to a logfile + # then write messages that are at least warnings to a logfile FileHandler('application.log', level='WARNING'), # errors should then be delivered by mail and also be kept # in the application log, so we let them bubble up. diff --git a/docs/sheet/layout.html b/docs/sheet/layout.html index e777773..1303614 100644 --- a/docs/sheet/layout.html +++ b/docs/sheet/layout.html @@ -12,7 +12,7 @@ {% endblock %} diff --git a/docs/sheet/static/sheet.css_t b/docs/sheet/static/sheet.css_t index e0c3734..7d518ec 100644 --- a/docs/sheet/static/sheet.css_t +++ b/docs/sheet/static/sheet.css_t @@ -143,3 +143,9 @@ font-size: small; margin: 0px; } + +a.headerlink { + font-size: 0.5em; + text-decoration: none; + padding-left: 0.2em; +} diff --git a/docs/stacks.rst b/docs/stacks.rst index 75eef24..0afb9a0 100644 --- a/docs/stacks.rst +++ b/docs/stacks.rst @@ -75,7 +75,7 @@ flag (the :attr:`~logbook.Handler.bubble` flag) which specifies if the next handler in the chain is supposed to get this record passed to. -If a handler is bubbeling it will give the record to the next handler, +If a handler is bubbling it will give the record to the next handler, even if it was properly handled. If it's not, it will stop promoting handlers further down the chain. Additionally there are so-called "blackhole" handlers (:class:`~logbook.NullHandler`) which stop processing diff --git a/docs/ticketing.rst b/docs/ticketing.rst index c6e1786..0252ad3 100644 --- a/docs/ticketing.rst +++ b/docs/ticketing.rst @@ -47,7 +47,7 @@ it will connect to a relational database with the help of `SQLAlchemy`_ and log into two tables there: tickets go into ``${prefix}tickets`` and occurrences go into ``${prefix}occurrences``. The default table prefix is -``'logbook_'`` but can be overriden. If the tables do not exist already, +``'logbook_'`` but can be overridden. If the tables do not exist already, the handler will create them. Here an example setup that logs into a postgres database:: diff --git a/logbook/__init__.py b/logbook/__init__.py index 86a1a2c..a8ffc81 100644 --- a/logbook/__init__.py +++ b/logbook/__init__.py @@ -21,10 +21,9 @@ GMailHandler, SyslogHandler, NullHandler, NTEventLogHandler, create_syshandler, StringFormatter, StringFormatterHandlerMixin, HashingHandlerMixin, LimitingHandlerMixin, WrapperHandler, - FingersCrossedHandler, GroupHandler) + FingersCrossedHandler, GroupHandler, GZIPCompressionHandler, BrotliCompressionHandler) from . import compat -__version__ = '0.11.4-dev' # create an anonymous default logger and provide all important # methods of that logger as global functions @@ -48,3 +47,5 @@ if os.environ.get('LOGBOOK_INSTALL_DEFAULT_HANDLER'): default_handler = StderrHandler() default_handler.push_application() + +from .__version__ import __version__ diff --git a/logbook/__version__.py b/logbook/__version__.py index 8e1395b..67bc602 100644 --- a/logbook/__version__.py +++ b/logbook/__version__.py @@ -1 +1 @@ -__version__ = "0.12.3" +__version__ = "1.3.0" diff --git a/logbook/_termcolors.py b/logbook/_termcolors.py index 0554197..0c42b3e 100644 --- a/logbook/_termcolors.py +++ b/logbook/_termcolors.py @@ -11,9 +11,7 @@ esc = "\x1b[" -codes = {} -codes[""] = "" -codes["reset"] = esc + "39;49;00m" +codes = {"": "", "reset": esc + "39;49;00m"} dark_colors = ["black", "darkred", "darkgreen", "brown", "darkblue", "purple", "teal", "lightgray"] diff --git a/logbook/base.py b/logbook/base.py index 419c559..9ad10b4 100644 --- a/logbook/base.py +++ b/logbook/base.py @@ -11,21 +11,24 @@ import os import sys import traceback +from collections import defaultdict +from datetime import datetime from itertools import chain from weakref import ref as weakref -from datetime import datetime -from logbook.concurrency import ( - thread_get_name, thread_get_ident, greenlet_get_ident) - -from logbook.helpers import ( - to_safe_json, parse_iso8601, cached_property, PY2, u, string_types, - iteritems, integer_types, xrange) + +from logbook.concurrency import (greenlet_get_ident, thread_get_ident, + thread_get_name) + +from logbook.helpers import (PY2, cached_property, integer_types, iteritems, + parse_iso8601, string_types, to_safe_json, u, + xrange) + try: from logbook._speedups import ( - group_reflected_property, ContextStackManager, StackedObject) + _missing, group_reflected_property, ContextStackManager, StackedObject) except ImportError: from logbook._fallback import ( - group_reflected_property, ContextStackManager, StackedObject) + _missing, group_reflected_property, ContextStackManager, StackedObject) _datetime_factory = datetime.utcnow @@ -37,6 +40,7 @@ :py:class:`LogRecord` instances. :param datetime_format: Indicates how to generate datetime objects. + Possible values are: "utc" @@ -45,6 +49,9 @@ "local" :py:attr:`LogRecord.time` will be a datetime in local time zone (but not time zone aware) + A `callable` returning datetime instances + :py:attr:`LogRecord.time` will be a datetime created by + :py:obj:`datetime_format` (possibly time zone aware) This function defaults to creating datetime objects in UTC time, using `datetime.utcnow() @@ -65,12 +72,30 @@ from datetime import datetime logbook.set_datetime_format("local") + Other uses rely on your supplied :py:obj:`datetime_format`. + Using `pytz `_ for example:: + + from datetime import datetime + import logbook + import pytz + + def utc_tz(): + return datetime.now(tz=pytz.utc) + + logbook.set_datetime_format(utc_tz) """ global _datetime_factory if datetime_format == "utc": _datetime_factory = datetime.utcnow elif datetime_format == "local": _datetime_factory = datetime.now + elif callable(datetime_format): + inst = datetime_format() + if not isinstance(inst, datetime): + raise ValueError("Invalid callable value, valid callable " + "should return datetime.datetime instances, " + "not %r" % (type(inst),)) + _datetime_factory = datetime_format else: raise ValueError("Invalid value %r. Valid values are 'utc' and " "'local'." % (datetime_format,)) @@ -142,29 +167,6 @@ return _level_names[level] except KeyError: raise LookupError('unknown level') - - -class ExtraDict(dict): - """A dictionary which returns ``u''`` on missing keys.""" - - if sys.version_info[:2] < (2, 5): - def __getitem__(self, key): - try: - return dict.__getitem__(self, key) - except KeyError: - return u('') - else: - def __missing__(self, key): - return u('') - - def copy(self): - return self.__class__(self) - - def __repr__(self): - return '%s(%s)' % ( - self.__class__.__name__, - dict.__repr__(self) - ) class _ExceptionCatcher(object): @@ -405,7 +407,9 @@ #: optional extra information as dictionary. This is the place #: where custom log processors can attach custom context sensitive #: data. - self.extra = ExtraDict(extra or ()) + + # TODO: Replace the lambda with str when we remove support for python 2 + self.extra = defaultdict(lambda: u'', extra or ()) #: If available, optionally the interpreter frame that pulled the #: heavy init. This usually points to somewhere in the dispatcher. #: Might not be available for all calls and is removed when the log @@ -506,7 +510,9 @@ self._channel = None if isinstance(self.time, string_types): self.time = parse_iso8601(self.time) - self.extra = ExtraDict(self.extra) + + # TODO: Replace the lambda with str when we remove support for python 2` + self.extra = defaultdict(lambda: u'', self.extra) return self def _format_message(self, msg, *args, **kwargs): @@ -573,6 +579,9 @@ frm = frm.f_back for _ in xrange(self.frame_correction): + if frm is None: + break + frm = frm.f_back return frm @@ -807,6 +816,32 @@ if not args: args = ('Uncaught exception occurred',) return _ExceptionCatcher(self, args, kwargs) + + def enable(self): + """Convenience method to enable this logger. + + :raises AttributeError: The disabled property is read-only, typically + because it was overridden in a subclass. + + .. versionadded:: 1.0 + """ + try: + self.disabled = False + except AttributeError: + raise AttributeError('The disabled property is read-only.') + + def disable(self): + """Convenience method to disable this logger. + + :raises AttributeError: The disabled property is read-only, typically + because it was overridden in a subclass. + + .. versionadded:: 1.0 + """ + try: + self.disabled = True + except AttributeError: + raise AttributeError('The disabled property is read-only.') def _log(self, level, args, kwargs): exc_info = kwargs.pop('exc_info', None) @@ -1016,6 +1051,42 @@ if self.processor is not None: self.processor(record) + def enable(self, force=False): + """Convenience method to enable this group. + + :param force: Force enable loggers that were explicitly set. + + :raises AttributeError: If ``force=True`` and the disabled property of + a logger is read-only, typically because it was + overridden in a subclass. + + .. versionadded:: 1.0 + """ + self.disabled = False + if force: + for logger in self.loggers: + rv = getattr(logger, '_disabled', _missing) + if rv is not _missing: + logger.enable() + + def disable(self, force=False): + """Convenience method to disable this group. + + :param force: Force disable loggers that were explicitly set. + + :raises AttributeError: If ``force=True`` and the disabled property of + a logger is read-only, typically because it was + overridden in a subclass. + + .. versionadded:: 1.0 + """ + self.disabled = True + if force: + for logger in self.loggers: + rv = getattr(logger, '_disabled', _missing) + if rv is not _missing: + logger.disable() + _default_dispatcher = RecordDispatcher() @@ -1027,6 +1098,5 @@ """ _default_dispatcher.call_handlers(record) - -# at that point we are save to import handler -from logbook.handlers import Handler +# at that point we are safe to import handler +from logbook.handlers import Handler # isort:skip diff --git a/logbook/compat.py b/logbook/compat.py index c3896db..b65ac00 100644 --- a/logbook/compat.py +++ b/logbook/compat.py @@ -9,12 +9,13 @@ :copyright: (c) 2010 by Armin Ronacher, Georg Brandl. :license: BSD, see LICENSE for more details. """ +import collections +import logging import sys -import logging import warnings +from datetime import date, datetime + import logbook -from datetime import date, datetime - from logbook.helpers import u, string_types, iteritems _epoch_ord = date(1970, 1, 1).toordinal() @@ -63,8 +64,12 @@ class LoggingCompatRecord(logbook.LogRecord): def _format_message(self, msg, *args, **kwargs): - assert not kwargs - return msg % tuple(args) + if kwargs: + assert not args + return msg % kwargs + else: + assert not kwargs + return msg % tuple(args) class RedirectLoggingHandler(logging.Handler): @@ -124,10 +129,17 @@ def convert_record(self, old_record): """Converts an old logging record into a logbook log record.""" + args = old_record.args + kwargs = None + + # Logging allows passing a mapping object, in which case args will be a mapping. + if isinstance(args, collections.Mapping): + kwargs = args + args = None record = LoggingCompatRecord(old_record.name, self.convert_level(old_record.levelno), - old_record.msg, old_record.args, - None, old_record.exc_info, + old_record.msg, args, + kwargs, old_record.exc_info, self.find_extra(old_record), self.find_caller(old_record)) record.time = self.convert_time(old_record.created) diff --git a/logbook/concurrency.py b/logbook/concurrency.py index ccf80ad..b7a6758 100644 --- a/logbook/concurrency.py +++ b/logbook/concurrency.py @@ -59,7 +59,7 @@ # We trust the GIL here so we can do this comparison w/o locking. if tid_gid == self._owner: - self._count = self._count + 1 + self._count += 1 return True greenlet_lock = self._get_greenlet_lock() @@ -100,7 +100,7 @@ if tid_gid != self._owner: raise RuntimeError("cannot release un-acquired lock") - self._count = self._count - 1 + self._count -= 1 if not self._count: self._owner = None gid = self._wait_queue.pop(0) diff --git a/logbook/handlers.py b/logbook/handlers.py index 821e85f..e00b6c0 100644 --- a/logbook/handlers.py +++ b/logbook/handlers.py @@ -15,25 +15,29 @@ import stat import errno import socket +import gzip +import math try: from hashlib import sha1 except ImportError: from sha import new as sha1 import traceback +import collections from datetime import datetime, timedelta from collections import deque from textwrap import dedent from logbook.base import ( CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, NOTSET, level_name_property, - _missing, lookup_level, Flags, ContextObject, ContextStackManager) + _missing, lookup_level, Flags, ContextObject, ContextStackManager, + _datetime_factory) from logbook.helpers import ( rename, b, _is_text_stream, is_unicode, PY2, zip, xrange, string_types, integer_types, reraise, u, with_metaclass) from logbook.concurrency import new_fine_grained_lock DEFAULT_FORMAT_STRING = u( - '[{record.time:%Y-%m-%d %H:%M:%S.%f}] ' + '[{record.time:%Y-%m-%d %H:%M:%S.%f%z}] ' '{record.level_name}: {record.channel}: {record.message}') SYSLOG_FORMAT_STRING = u('{record.channel}: {record.message}') @@ -581,9 +585,13 @@ try: self.ensure_stream_is_open() self.write(self.encode(msg)) - self.flush() + if self.should_flush(): + self.flush() finally: self.lock.release() + + def should_flush(self): + return True class FileHandler(StreamHandler): @@ -641,6 +649,79 @@ self._open() +class GZIPCompressionHandler(FileHandler): + def __init__(self, filename, encoding=None, level=NOTSET, + format_string=None, delay=False, filter=None, bubble=False, compression_quality=9): + + self._compression_quality = compression_quality + super(GZIPCompressionHandler, self).__init__(filename, mode='wb', encoding=encoding, level=level, + format_string=format_string, delay=delay, filter=filter, bubble=bubble) + + def _open(self, mode=None): + if mode is None: + mode = self._mode + self.stream = gzip.open(self._filename, mode, compresslevel=self._compression_quality) + + def write(self, item): + if isinstance(item, str): + item = item.encode(encoding=self.encoding) + self.ensure_stream_is_open() + self.stream.write(item) + + def should_flush(self): + # gzip manages writes independently. Flushing prematurely could mean + # duplicate flushes and thus bloated files + return False + + +class BrotliCompressionHandler(FileHandler): + def __init__(self, filename, encoding=None, level=NOTSET, + format_string=None, delay=False, filter=None, bubble=False, + compression_window_size=4*1024**2, compression_quality=11): + super(BrotliCompressionHandler, self).__init__(filename, mode='wb', encoding=encoding, level=level, + format_string=format_string, delay=delay, filter=filter, bubble=bubble) + try: + from brotli import Compressor + except ImportError: + raise RuntimeError('The brotli library is required for ' + 'the BrotliCompressionHandler.') + + max_window_size = int(math.log(compression_window_size, 2)) + self._compressor = Compressor(quality=compression_quality, lgwin=max_window_size) + + def _open(self, mode=None): + if mode is None: + mode = self._mode + self.stream = io.open(self._filename, mode) + + def write(self, item): + if isinstance(item, str): + item = item.encode(encoding=self.encoding) + ret = self._compressor.process(item) + if ret: + self.ensure_stream_is_open() + self.stream.write(ret) + super(BrotliCompressionHandler, self).flush() + + def should_flush(self): + return False + + def flush(self): + if self._compressor is not None: + ret = self._compressor.flush() + if ret: + self.ensure_stream_is_open() + self.stream.write(ret) + super(BrotliCompressionHandler, self).flush() + + def close(self): + if self._compressor is not None: + self.ensure_stream_is_open() + self.stream.write(self._compressor.finish()) + self._compressor = None + super(BrotliCompressionHandler, self).close() + + class MonitoringFileHandler(FileHandler): """A file handler that will check if the file was moved while it was open. This might happen on POSIX systems if an application like @@ -667,7 +748,7 @@ st = os.stat(self._filename) except OSError: e = sys.exc_info()[1] - if e.errno != 2: + if e.errno != errno.ENOENT: raise self._last_stat = None, None else: @@ -782,29 +863,68 @@ By default it will keep all these files around, if you want to limit them, you can specify a `backup_count`. + + You may supply an optional `rollover_format`. This allows you to specify + the format for the filenames of rolled-over files. + the format as + + So for example if you configure your handler like this:: + + handler = TimedRotatingFileHandler( + '/var/log/foo.log', + date_format='%Y-%m-%d', + rollover_format='{basename}{ext}.{timestamp}') + + The filenames for the logfiles will look like this:: + + /var/log/foo.log.2010-01-10 + /var/log/foo.log.2010-01-11 + ... + + Finally, an optional argument `timed_filename_for_current` may be set to + false if you wish to have the current log file match the supplied filename + until it is rolled over """ def __init__(self, filename, mode='a', encoding='utf-8', level=NOTSET, format_string=None, date_format='%Y-%m-%d', - backup_count=0, filter=None, bubble=False): + backup_count=0, filter=None, bubble=False, + timed_filename_for_current=True, + rollover_format='{basename}-{timestamp}{ext}'): + self.date_format = date_format + self.backup_count = backup_count + + self.rollover_format = rollover_format + + self.original_filename = filename + self.basename, self.ext = os.path.splitext(os.path.abspath(filename)) + self.timed_filename_for_current = timed_filename_for_current + + self._timestamp = self._get_timestamp(_datetime_factory()) + timed_filename = self.generate_timed_filename(self._timestamp) + + if self.timed_filename_for_current: + filename = timed_filename + FileHandler.__init__(self, filename, mode, encoding, level, format_string, True, filter, bubble) - self.date_format = date_format - self.backup_count = backup_count - self._fn_parts = os.path.splitext(os.path.abspath(filename)) - self._filename = None - - def _get_timed_filename(self, datetime): - return (datetime.strftime('-' + self.date_format) - .join(self._fn_parts)) - - def should_rollover(self, record): - fn = self._get_timed_filename(record.time) - rv = self._filename is not None and self._filename != fn - # remember the current filename. In case rv is True, the rollover - # performing function will already have the new filename - self._filename = fn - return rv + + def _get_timestamp(self, datetime): + """ + Fetches a formatted string witha timestamp of the given datetime + """ + return datetime.strftime(self.date_format) + + def generate_timed_filename(self, timestamp): + """ + Produces a filename that includes a timestamp in the format supplied + to the handler at init time. + """ + timed_filename = self.rollover_format.format( + basename=self.basename, + timestamp=timestamp, + ext=self.ext) + return timed_filename def files_to_delete(self): """Returns a list with the files that have to be deleted when @@ -814,8 +934,12 @@ files = [] for filename in os.listdir(directory): filename = os.path.join(directory, filename) - if (filename.startswith(self._fn_parts[0] + '-') and - filename.endswith(self._fn_parts[1])): + regex = self.rollover_format.format( + basename=re.escape(self.basename), + timestamp='.+', + ext=re.escape(self.ext), + ) + if re.match(regex, filename): files.append((os.path.getmtime(filename), filename)) files.sort() if self.backup_count > 1: @@ -823,19 +947,30 @@ else: return files[:] - def perform_rollover(self): - self.stream.close() + def perform_rollover(self, new_timestamp): + if self.stream is not None: + self.stream.close() + if self.backup_count > 0: for time, filename in self.files_to_delete(): os.remove(filename) + + if self.timed_filename_for_current: + self._filename = self.generate_timed_filename(new_timestamp) + else: + filename = self.generate_timed_filename(self._timestamp) + os.rename(self._filename, filename) + self._timestamp = new_timestamp + self._open('w') def emit(self, record): msg = self.format(record) self.lock.acquire() try: - if self.should_rollover(record): - self.perform_rollover() + new_timestamp = self._get_timestamp(record.time) + if new_timestamp != self._timestamp: + self.perform_rollover(new_timestamp) self.write(self.encode(msg)) self.flush() finally: @@ -970,7 +1105,7 @@ def _test_for(self, message=None, channel=None, level=None): def _match(needle, haystack): - "Matches both compiled regular expressions and strings" + """Matches both compiled regular expressions and strings""" if isinstance(needle, REGTYPE) and needle.search(haystack): return True if needle == haystack: @@ -1014,14 +1149,42 @@ The default timedelta is 60 seconds (one minute). - The mail handler is sending mails in a blocking manner. If you are not + The mail handler sends mails in a blocking manner. If you are not using some centralized system for logging these messages (with the help of ZeroMQ or others) and the logging system slows you down you can wrap the handler in a :class:`logbook.queues.ThreadedWrapperHandler` that will then send the mails in a background thread. + `server_addr` can be a tuple of host and port, or just a string containing + the host to use the default port (25, or 465 if connecting securely.) + + `credentials` can be a tuple or dictionary of arguments that will be passed + to :py:meth:`smtplib.SMTP.login`. + + `secure` can be a tuple, dictionary, or boolean. As a boolean, this will + simply enable or disable a secure connection. The tuple is unpacked as + parameters `keyfile`, `certfile`. As a dictionary, `secure` should contain + those keys. For backwards compatibility, ``secure=()`` will enable a secure + connection. If `starttls` is enabled (default), these parameters will be + passed to :py:meth:`smtplib.SMTP.starttls`, otherwise + :py:class:`smtplib.SMTP_SSL`. + + .. versionchanged:: 0.3 The handler supports the batching system now. + + .. versionadded:: 1.0 + `starttls` parameter added to allow disabling STARTTLS for SSL + connections. + + .. versionchanged:: 1.0 + If `server_addr` is a string, the default port will be used. + + .. versionchanged:: 1.0 + `credentials` parameter can now be a dictionary of keyword arguments. + + .. versionchanged:: 1.0 + `secure` can now be a dictionary or boolean in addition to to a tuple. """ default_format_string = MAIL_FORMAT_STRING default_related_format_string = MAIL_RELATED_FORMAT_STRING @@ -1039,7 +1202,7 @@ server_addr=None, credentials=None, secure=None, record_limit=None, record_delta=None, level=NOTSET, format_string=None, related_format_string=None, - filter=None, bubble=False): + filter=None, bubble=False, starttls=True): Handler.__init__(self, level, filter, bubble) StringFormatterHandlerMixin.__init__(self, format_string) LimitingHandlerMixin.__init__(self, record_limit, record_delta) @@ -1054,6 +1217,7 @@ if related_format_string is None: related_format_string = self.default_related_format_string self.related_format_string = related_format_string + self.starttls = starttls def _get_related_format_string(self): if isinstance(self.related_formatter, StringFormatter): @@ -1141,27 +1305,70 @@ mail.get_payload(), title, '\r\n\r\n'.join(body.rstrip() for body in related) - )) + ), 'UTF-8') return mail def get_connection(self): """Returns an SMTP connection. By default it reconnects for each sent mail. """ - from smtplib import SMTP, SMTP_PORT, SMTP_SSL_PORT + from smtplib import SMTP, SMTP_SSL, SMTP_PORT, SMTP_SSL_PORT if self.server_addr is None: host = '127.0.0.1' port = self.secure and SMTP_SSL_PORT or SMTP_PORT else: - host, port = self.server_addr - con = SMTP() - con.connect(host, port) + try: + host, port = self.server_addr + except ValueError: + # If server_addr is a string, the tuple unpacking will raise + # ValueError, and we can use the default port. + host = self.server_addr + port = self.secure and SMTP_SSL_PORT or SMTP_PORT + + # Previously, self.secure was passed as con.starttls(*self.secure). This + # meant that starttls couldn't be used without a keyfile and certfile + # unless an empty tuple was passed. See issue #94. + # + # The changes below allow passing: + # - secure=True for secure connection without checking identity. + # - dictionary with keys 'keyfile' and 'certfile'. + # - tuple to be unpacked to variables keyfile and certfile. + # - secure=() equivalent to secure=True for backwards compatibility. + # - secure=False equivalent to secure=None to disable. + if isinstance(self.secure, collections.Mapping): + keyfile = self.secure.get('keyfile', None) + certfile = self.secure.get('certfile', None) + elif isinstance(self.secure, collections.Iterable): + # Allow empty tuple for backwards compatibility + if len(self.secure) == 0: + keyfile = certfile = None + else: + keyfile, certfile = self.secure + else: + keyfile = certfile = None + + # Allow starttls to be disabled by passing starttls=False. + if not self.starttls and self.secure: + con = SMTP_SSL(host, port, keyfile=keyfile, certfile=certfile) + else: + con = SMTP(host, port) + if self.credentials is not None: - if self.secure is not None: + secure = self.secure + if self.starttls and secure is not None and secure is not False: con.ehlo() - con.starttls(*self.secure) + con.starttls(keyfile=keyfile, certfile=certfile) con.ehlo() - con.login(*self.credentials) + + # Allow credentials to be a tuple or dict. + if isinstance(self.credentials, collections.Mapping): + credentials_args = () + credentials_kwargs = self.credentials + else: + credentials_args = self.credentials + credentials_kwargs = dict() + + con.login(*credentials_args, **credentials_kwargs) return con def close_connection(self, con): @@ -1175,7 +1382,7 @@ pass def deliver(self, msg, recipients): - """Delivers the given message to a list of recpients.""" + """Delivers the given message to a list of recipients.""" con = self.get_connection() try: con.sendmail(self.from_addr, recipients, msg.as_string()) @@ -1227,7 +1434,7 @@ def __init__(self, account_id, password, recipients, **kw): super(GMailHandler, self).__init__( - account_id, recipients, secure=(), + account_id, recipients, secure=True, server_addr=("smtp.gmail.com", 587), credentials=(account_id, password), **kw) @@ -1431,9 +1638,15 @@ return self._type_map.get(record.level, self._default_type) def get_event_category(self, record): + """Returns the event category for the record. Override this if you want + to specify your own categories. This version returns 0. + """ return 0 def get_message_id(self, record): + """Returns the message ID (EventID) for the record. Override this if + you want to specify your own ID. This version returns 1. + """ return 1 def emit(self, record): diff --git a/logbook/helpers.py b/logbook/helpers.py index 5c228f0..d53bcf0 100644 --- a/logbook/helpers.py +++ b/logbook/helpers.py @@ -167,9 +167,9 @@ os.rename(src, dst) except OSError: e = sys.exc_info()[1] - if e.errno != errno.EEXIST: + if e.errno not in (errno.EEXIST, errno.EACCES): raise - old = "%s-%08x" % (dst, random.randint(0, sys.maxint)) + old = "%s-%08x" % (dst, random.randint(0, 2 ** 31 - 1)) os.rename(dst, old) os.rename(src, dst) try: diff --git a/logbook/more.py b/logbook/more.py index a61f736..2d15f80 100644 --- a/logbook/more.py +++ b/logbook/more.py @@ -10,8 +10,9 @@ """ import re import os +import platform + from collections import defaultdict -from cgi import parse_qsl from functools import partial from logbook.base import ( @@ -20,14 +21,22 @@ Handler, StringFormatter, StringFormatterHandlerMixin, StderrHandler) from logbook._termcolors import colorize from logbook.helpers import PY2, string_types, iteritems, u - from logbook.ticketing import TicketingHandler as DatabaseHandler from logbook.ticketing import BackendBase +try: + import riemann_client.client + import riemann_client.transport +except ImportError: + riemann_client = None + #from riemann_client.transport import TCPTransport, UDPTransport, BlankTransport + + if PY2: from urllib import urlencode + from urlparse import parse_qsl else: - from urllib.parse import urlencode + from urllib.parse import parse_qsl, urlencode _ws_re = re.compile(r'(\s+)(?u)') TWITTER_FORMAT_STRING = u( @@ -220,6 +229,33 @@ self.tweet(self.format(record)) +class SlackHandler(Handler, StringFormatterHandlerMixin): + + """A handler that logs to slack. Requires that you sign up an + application on slack and request an api token. Furthermore the + slacker library has to be installed. + """ + + def __init__(self, api_token, channel, level=NOTSET, format_string=None, filter=None, + bubble=False): + + Handler.__init__(self, level, filter, bubble) + StringFormatterHandlerMixin.__init__(self, format_string) + self.api_token = api_token + + try: + from slacker import Slacker + except ImportError: + raise RuntimeError('The slacker library is required for ' + 'the SlackHandler.') + + self.channel = channel + self.slack = Slacker(api_token) + + def emit(self, record): + self.slack.chat.post_message(channel=self.channel, text=self.format(record)) + + class JinjaFormatter(object): """A formatter object that makes it easy to format using a Jinja 2 template instead of a format string. @@ -288,15 +324,36 @@ """A mixin class that does colorizing. .. versionadded:: 0.3 - """ + .. versionchanged:: 1.0.0 + Added Windows support if `colorama`_ is installed. + + .. _`colorama`: https://pypi.python.org/pypi/colorama + """ + _use_color = None + + def force_color(self): + """Force colorizing the stream (`should_colorize` will return True) + """ + self._use_color = True + + def forbid_color(self): + """Forbid colorizing the stream (`should_colorize` will return False) + """ + self._use_color = False def should_colorize(self, record): """Returns `True` if colorizing should be applied to this record. The default implementation returns `True` if the - stream is a tty and we are not executing on windows. + stream is a tty. If we are executing on Windows, colorama must be + installed. """ if os.name == 'nt': - return False + try: + import colorama + except ImportError: + return False + if self._use_color is not None: + return self._use_color isatty = getattr(self.stream, 'isatty', None) return isatty and isatty() @@ -323,7 +380,22 @@ not colorize on Windows systems. .. versionadded:: 0.3 - """ + .. versionchanged:: 1.0 + Added Windows support if `colorama`_ is installed. + + .. _`colorama`: https://pypi.python.org/pypi/colorama + """ + def __init__(self, *args, **kwargs): + StderrHandler.__init__(self, *args, **kwargs) + + # Try import colorama so that we work on Windows. colorama.init is a + # noop on other operating systems. + try: + import colorama + except ImportError: + pass + else: + colorama.init() # backwards compat. Should go away in some future releases @@ -424,3 +496,79 @@ dispatch = dispatch_record dispatch(record) self.clear() + + +class RiemannHandler(Handler): + + """ + A handler that sends logs as events to Riemann. + """ + + def __init__(self, + host, + port, + message_type="tcp", + ttl=60, + flush_threshold=10, + bubble=False, + filter=None, + level=NOTSET): + """ + :param host: riemann host + :param port: riemann port + :param message_type: selects transport. Currently available 'tcp' and 'udp' + :param ttl: defines time to live in riemann + :param flush_threshold: count of events after which we send to riemann + """ + if riemann_client is None: + raise NotImplementedError("The Riemann handler requires the riemann_client package") # pragma: no cover + Handler.__init__(self, level, filter, bubble) + self.host = host + self.port = port + self.ttl = ttl + self.queue = [] + self.flush_threshold = flush_threshold + if message_type == "tcp": + self.transport = riemann_client.transport.TCPTransport + elif message_type == "udp": + self.transport = riemann_client.transport.UDPTransport + elif message_type == "test": + self.transport = riemann_client.transport.BlankTransport + else: + msg = ("Currently supported message types for RiemannHandler are: {0}. \ + {1} is not supported." + .format(",".join(["tcp", "udp", "test"]), message_type)) + raise RuntimeError(msg) + + def record_to_event(self, record): + from time import time + tags = ["log", record.level_name] + msg = str(record.exc_info[1]) if record.exc_info else record.msg + channel_name = str(record.channel) if record.channel else "unknown" + if any([record.level_name == keywords + for keywords in ["ERROR", "EXCEPTION"]]): + state = "error" + else: + state = "ok" + return {"metric_f": 1.0, + "tags": tags, + "description": msg, + "time": int(time()), + "ttl": self.ttl, + "host": platform.node(), + "service": "{0}.{1}".format(channel_name, os.getpid()), + "state": state + } + + def _flush_events(self): + with riemann_client.client.QueuedClient(self.transport(self.host, self.port)) as cl: + for event in self.queue: + cl.event(**event) + cl.flush() + self.queue = [] + + def emit(self, record): + self.queue.append(self.record_to_event(record)) + + if len(self.queue) == self.flush_threshold: + self._flush_events() diff --git a/logbook/notifiers.py b/logbook/notifiers.py index a83ed67..ce9468a 100644 --- a/logbook/notifiers.py +++ b/logbook/notifiers.py @@ -270,7 +270,8 @@ def __init__(self, application_name=None, apikey=None, userkey=None, device=None, priority=0, sound=None, record_limit=None, - record_delta=None, level=NOTSET, filter=None, bubble=False): + record_delta=None, level=NOTSET, filter=None, bubble=False, + max_title_len=100, max_message_len=512): super(PushoverHandler, self).__init__(None, record_limit, record_delta, level, filter, bubble) @@ -282,22 +283,25 @@ self.priority = priority self.sound = sound + self.max_title_len = max_title_len + self.max_message_len = max_message_len + if self.application_name is None: self.title = None - elif len(self.application_name) > 100: - self.title = "%s..." % (self.application_name[:-3],) else: - self.title = self.application_name + self.title = self._crop(self.application_name, self.max_title_len) if self.priority not in [-2, -1, 0, 1]: self.priority = 0 - def emit(self, record): - - if len(record.message) > 512: - message = "%s..." % (record.message[:-3],) + def _crop(self, msg, max_len): + if max_len is not None and max_len > 0 and len(msg) > max_len: + return "%s..." % (msg[:max_len-3],) else: - message = record.message + return msg + + def emit(self, record): + message = self._crop(record.message, self.max_message_len) body_dict = { 'token': self.apikey, diff --git a/logbook/queues.py b/logbook/queues.py index cdb58ed..68ead9f 100644 --- a/logbook/queues.py +++ b/logbook/queues.py @@ -43,7 +43,7 @@ More info about the default buffer size: wp.me/p3tYJu-3b """ def __init__(self, host='127.0.0.1', port=6379, key='redis', - extra_fields={}, flush_threshold=128, flush_time=1, + extra_fields=None, flush_threshold=128, flush_time=1, level=NOTSET, filter=None, password=False, bubble=True, context=None, push_method='rpush'): Handler.__init__(self, level, filter, bubble) @@ -62,7 +62,7 @@ raise ResponseError( 'The password provided is apparently incorrect') self.key = key - self.extra_fields = extra_fields + self.extra_fields = extra_fields or {} self.flush_threshold = flush_threshold self.queue = [] self.lock = Lock() @@ -562,7 +562,7 @@ rv = self.queue.get() else: try: - rv = self.queue.get(block=False, timeout=timeout) + rv = self.queue.get(block=True, timeout=timeout) except Empty: return None return LogRecord.from_dict(rv) diff --git a/logbook/ticketing.py b/logbook/ticketing.py index 1d882c7..7321fa3 100644 --- a/logbook/ticketing.py +++ b/logbook/ticketing.py @@ -502,6 +502,6 @@ def emit(self, record): """Emits a single record and writes it to the database.""" - hash = self.hash_record(record) + hash = self.hash_record(record).encode('utf-8') data = self.process_record(record, hash) self.record_ticket(record, data, hash) diff --git a/logbook/utils.py b/logbook/utils.py index 09bf1c5..21df7cc 100644 --- a/logbook/utils.py +++ b/logbook/utils.py @@ -3,45 +3,53 @@ import sys import threading -from .base import Logger +from .base import Logger, DEBUG from .helpers import string_types -from logbook import debug as logbook_debug class _SlowContextNotifier(object): - def __init__(self, threshold, logger_func, args, kwargs): - self.logger_func = logger_func - self.args = args - self.kwargs = kwargs or {} - self.evt = threading.Event() - self.threshold = threshold - self.thread = threading.Thread(target=self._notifier) - - def _notifier(self): - self.evt.wait(timeout=self.threshold) - if not self.evt.is_set(): - self.logger_func(*self.args, **self.kwargs) + def __init__(self, threshold, func): + self.timer = threading.Timer(threshold, func) def __enter__(self): - self.thread.start() + self.timer.start() return self def __exit__(self, *_): - self.evt.set() - self.thread.join() + self.timer.cancel() -def logged_if_slow(message, threshold=1, func=logbook_debug, args=None, - kwargs=None): - """Logs a message (by default using the global debug logger) if a certain - context containing a set of operations is too slow +_slow_logger = Logger('Slow') - >>> with logged_if_slow('too slow!'): - ... ... + +def logged_if_slow(*args, **kwargs): + """Context manager that logs if operations within take longer than + `threshold` seconds. + + :param threshold: Number of seconds (or fractions thereof) allwoed before + logging occurs. The default is 1 second. + :param logger: :class:`~logbook.Logger` to use. The default is a 'slow' + logger. + :param level: Log level. The default is `DEBUG`. + :param func: (Deprecated). Function to call to perform logging. + + The remaining parameters are passed to the + :meth:`~logbook.base.LoggerMixin.log` method. """ - full_args = (message, ) if args is None else (message, ) + tuple(args) - return _SlowContextNotifier(threshold, func, full_args, kwargs) + threshold = kwargs.pop('threshold', 1) + func = kwargs.pop('func', None) + if func is None: + logger = kwargs.pop('logger', _slow_logger) + level = kwargs.pop('level', DEBUG) + func = functools.partial(logger.log, level, *args, **kwargs) + else: + if 'logger' in kwargs or 'level' in kwargs: + raise TypeError("If using deprecated func parameter, 'logger' and" + " 'level' arguments cannot be passed.") + func = functools.partial(func, *args, **kwargs) + + return _SlowContextNotifier(threshold, func) class _Local(threading.local): @@ -56,6 +64,8 @@ >>> with suppressed_deprecations(): ... call_some_deprecated_logic() + + .. versionadded:: 0.12 """ prev_enabled = _local.enabled _local.enabled = False @@ -157,7 +167,9 @@ ... pass This will cause a warning log to be emitted when the function gets called, - with the correct filename/lineno + with the correct filename/lineno. + + .. versionadded:: 0.12 """ if isinstance(func, string_types): assert message is None diff --git a/scripts/test_setup.py b/scripts/test_setup.py index b380aa6..2ebd976 100644 --- a/scripts/test_setup.py +++ b/scripts/test_setup.py @@ -10,13 +10,9 @@ "pytest", "pyzmq", "sqlalchemy", + "Jinja2", ] - if (3, 2) <= python_version < (3, 3): - deps.append("markupsafe==0.15") - deps.append("Jinja2==2.6") - else: - deps.append("Jinja2") print("Setting up dependencies...") result = pip.main(["install"] + deps) sys.exit(result) diff --git a/setup.py b/setup.py index 45da0e2..6a925d5 100644 --- a/setup.py +++ b/setup.py @@ -53,26 +53,34 @@ """ import os +import platform import sys -from setuptools import setup, Extension, Feature +from itertools import chain + from distutils.command.build_ext import build_ext from distutils.errors import ( CCompilerError, DistutilsExecError, DistutilsPlatformError) - - -extra = {} +from setuptools import Distribution as _Distribution, Extension, setup +from setuptools.command.test import test as TestCommand + cmdclass = {} - - -class BuildFailed(Exception): - pass - +if sys.version_info < (2, 6): + raise Exception('Logbook requires Python 2.6 or higher.') + +cpython = platform.python_implementation() == 'CPython' + +ext_modules = [Extension('logbook._speedups', sources=['logbook/_speedups.c'])] ext_errors = (CCompilerError, DistutilsExecError, DistutilsPlatformError) -if sys.platform == 'win32' and sys.version_info > (2, 6): +if sys.platform == 'win32': # 2.6's distutils.msvc9compiler can raise an IOError when failing to # find the compiler ext_errors += (IOError,) + + +class BuildFailed(Exception): + def __init__(self): + self.cause = sys.exc_info()[1] # work around py 2/3 different syntax class ve_build_ext(build_ext): @@ -89,26 +97,90 @@ build_ext.build_extension(self, ext) except ext_errors: raise BuildFailed() + except ValueError: + # this can happen on Windows 64 bit, see Python issue 7511 + if "'path'" in str(sys.exc_info()[1]): # works with both py 2/3 + raise BuildFailed() + raise cmdclass['build_ext'] = ve_build_ext -# Don't try to compile the extension if we're running on PyPy -if (os.path.isfile('logbook/_speedups.c') and - not hasattr(sys, "pypy_translation_info")): - speedups = Feature('optional C speed-enhancement module', standard=True, - ext_modules=[Extension('logbook._speedups', - ['logbook/_speedups.c'])]) -else: - speedups = None - - -with open(os.path.join(os.path.dirname(__file__), "logbook", "__version__.py")) as version_file: + + +class Distribution(_Distribution): + + def has_ext_modules(self): + # We want to always claim that we have ext_modules. This will be fine + # if we don't actually have them (such as on PyPy) because nothing + # will get built, however we don't want to provide an overally broad + # Wheel package when building a wheel without C support. This will + # ensure that Wheel knows to treat us as if the build output is + # platform specific. + return True + + +class PyTest(TestCommand): + # from https://pytest.org/latest/goodpractises.html\ + # #integration-with-setuptools-test-commands + user_options = [('pytest-args=', 'a', 'Arguments to pass to py.test')] + + default_options = ['tests'] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.pytest_args = '' + + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main( + ' '.join(self.default_options) + ' ' + self.pytest_args) + sys.exit(errno) + +cmdclass['test'] = PyTest + + +def status_msgs(*msgs): + print('*' * 75) + for msg in msgs: + print(msg) + print('*' * 75) + +version_file_path = os.path.join( + os.path.dirname(__file__), 'logbook', '__version__.py') + +with open(version_file_path) as version_file: exec(version_file.read()) # pylint: disable=W0122 - -def run_setup(with_binary): - features = {} - if with_binary and speedups is not None: - features['speedups'] = speedups +extras_require = dict() +extras_require['test'] = set(['pytest', 'pytest-cov']) + +if sys.version_info[:2] < (3, 3): + extras_require['test'] |= set(['mock']) + +extras_require['dev'] = set(['cython']) | extras_require['test'] + +extras_require['execnet'] = set(['execnet>=1.0.9']) +extras_require['sqlalchemy'] = set(['sqlalchemy']) +extras_require['redis'] = set(['redis']) +extras_require['zmq'] = set(['pyzmq']) +extras_require['jinja'] = set(['Jinja2']) +extras_require['compression'] = set(['brotli']) + +extras_require['all'] = set(chain.from_iterable(extras_require.values())) + + +def run_setup(with_cext): + kwargs = {} + if with_cext: + kwargs['ext_modules'] = ext_modules + else: + kwargs['ext_modules'] = [] + setup( name='Logbook', version=__version__, @@ -122,41 +194,49 @@ zip_safe=False, platforms='any', cmdclass=cmdclass, + tests_require=['pytest'], classifiers=[ - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], - features=features, - install_requires=[ - ], - **extra + extras_require=extras_require, + distclass=Distribution, + **kwargs ) - -def echo(msg=''): - sys.stdout.write(msg + '\n') - - -try: - run_setup(True) -except BuildFailed: - LINE = '=' * 74 - BUILD_EXT_WARNING = ('WARNING: The C extension could not be compiled, ' - 'speedups are not enabled.') - - echo(LINE) - echo(BUILD_EXT_WARNING) - echo('Failure information, if any, is above.') - echo('Retrying the build without the C extension now.') - echo() - +if not cpython: run_setup(False) - - echo(LINE) - echo(BUILD_EXT_WARNING) - echo('Plain-Python installation succeeded.') - echo(LINE) + status_msgs( + 'WARNING: C extensions are not supported on ' + + 'this Python platform, speedups are not enabled.', + 'Plain-Python build succeeded.' + ) +elif os.environ.get('DISABLE_LOGBOOK_CEXT'): + run_setup(False) + status_msgs( + 'DISABLE_LOGBOOK_CEXT is set; ' + + 'not attempting to build C extensions.', + 'Plain-Python build succeeded.' + ) +else: + try: + run_setup(True) + except BuildFailed as exc: + status_msgs( + exc.cause, + 'WARNING: The C extension could not be compiled, ' + + 'speedups are not enabled.', + 'Failure information, if any, is above.', + 'Retrying the build without the C extension now.' + ) + + run_setup(False) + + status_msgs( + 'WARNING: The C extension could not be compiled, ' + + 'speedups are not enabled.', + 'Plain-Python build succeeded.' + ) diff --git a/tests/test_ci.py b/tests/test_ci.py new file mode 100644 index 0000000..5486163 --- /dev/null +++ b/tests/test_ci.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +import os + +import pytest + +from .utils import appveyor, travis + +@appveyor +def test_appveyor_speedups(): + if os.environ.get('CYBUILD'): + import logbook._speedups + else: + with pytest.raises(ImportError): + import logbook._speedups + +@travis +def test_travis_speedups(): + if os.environ.get('CYBUILD'): + import logbook._speedups + else: + with pytest.raises(ImportError): + import logbook._speedups diff --git a/tests/test_file_handler.py b/tests/test_file_handler.py index ba6feda..2c0848a 100644 --- a/tests/test_file_handler.py +++ b/tests/test_file_handler.py @@ -4,7 +4,8 @@ import logbook from logbook.helpers import u, xrange - +import gzip +import brotli from .utils import capturing_stderr_context, LETTERS @@ -127,3 +128,98 @@ with open(str(tmpdir.join('trot-2010-01-07.log'))) as f: assert f.readline().rstrip() == '[01:00] Third One' assert f.readline().rstrip() == '[02:00] Third One' + +@pytest.mark.parametrize("backup_count", [1, 3]) +def test_timed_rotating_file_handler__rollover_format(tmpdir, activation_strategy, backup_count): + basename = str(tmpdir.join('trot.log')) + handler = logbook.TimedRotatingFileHandler( + basename, backup_count=backup_count, + rollover_format='{basename}{ext}.{timestamp}', + ) + handler.format_string = '[{record.time:%H:%M}] {record.message}' + + def fake_record(message, year, month, day, hour=0, + minute=0, second=0): + lr = logbook.LogRecord('Test Logger', logbook.WARNING, + message) + lr.time = datetime(year, month, day, hour, minute, second) + return lr + + with activation_strategy(handler): + for x in xrange(10): + handler.handle(fake_record('First One', 2010, 1, 5, x + 1)) + for x in xrange(20): + handler.handle(fake_record('Second One', 2010, 1, 6, x + 1)) + for x in xrange(10): + handler.handle(fake_record('Third One', 2010, 1, 7, x + 1)) + for x in xrange(20): + handler.handle(fake_record('Last One', 2010, 1, 8, x + 1)) + + files = sorted(x for x in os.listdir(str(tmpdir)) if x.startswith('trot')) + + assert files == ['trot.log.2010-01-0{0}'.format(i) + for i in xrange(5, 9)][-backup_count:] + with open(str(tmpdir.join('trot.log.2010-01-08'))) as f: + assert f.readline().rstrip() == '[01:00] Last One' + assert f.readline().rstrip() == '[02:00] Last One' + if backup_count > 1: + with open(str(tmpdir.join('trot.log.2010-01-07'))) as f: + assert f.readline().rstrip() == '[01:00] Third One' + assert f.readline().rstrip() == '[02:00] Third One' + +@pytest.mark.parametrize("backup_count", [1, 3]) +def test_timed_rotating_file_handler__not_timed_filename_for_current(tmpdir, activation_strategy, backup_count): + basename = str(tmpdir.join('trot.log')) + handler = logbook.TimedRotatingFileHandler( + basename, backup_count=backup_count, + rollover_format='{basename}{ext}.{timestamp}', + timed_filename_for_current=False, + ) + handler._timestamp = handler._get_timestamp(datetime(2010, 1, 5)) + handler.format_string = '[{record.time:%H:%M}] {record.message}' + + def fake_record(message, year, month, day, hour=0, + minute=0, second=0): + lr = logbook.LogRecord('Test Logger', logbook.WARNING, + message) + lr.time = datetime(year, month, day, hour, minute, second) + return lr + + with activation_strategy(handler): + for x in xrange(10): + handler.handle(fake_record('First One', 2010, 1, 5, x + 1)) + for x in xrange(20): + handler.handle(fake_record('Second One', 2010, 1, 6, x + 1)) + for x in xrange(10): + handler.handle(fake_record('Third One', 2010, 1, 7, x + 1)) + for x in xrange(20): + handler.handle(fake_record('Last One', 2010, 1, 8, x + 1)) + + files = sorted(x for x in os.listdir(str(tmpdir)) if x.startswith('trot')) + + assert files == ['trot.log'] + ['trot.log.2010-01-0{0}'.format(i) + for i in xrange(5, 8)][-backup_count:] + with open(str(tmpdir.join('trot.log'))) as f: + assert f.readline().rstrip() == '[01:00] Last One' + assert f.readline().rstrip() == '[02:00] Last One' + if backup_count > 1: + with open(str(tmpdir.join('trot.log.2010-01-07'))) as f: + assert f.readline().rstrip() == '[01:00] Third One' + assert f.readline().rstrip() == '[02:00] Third One' + +def _decompress(input_file_name, use_gzip=True): + if use_gzip: + with gzip.open(input_file_name, 'rb') as in_f: + return in_f.read().decode() + else: + with open(input_file_name, 'rb') as in_f: + return brotli.decompress(in_f.read()).decode() + +@pytest.mark.parametrize("use_gzip", [True, False]) +def test_compression_file_handler(logfile, activation_strategy, logger, use_gzip): + handler = logbook.GZIPCompressionHandler(logfile) if use_gzip else logbook.BrotliCompressionHandler(logfile) + handler.format_string = '{record.level_name}:{record.channel}:{record.message}' + with activation_strategy(handler): + logger.warn('warning message') + handler.close() + assert _decompress(logfile, use_gzip) == 'WARNING:testlogger:warning message\n' diff --git a/tests/test_groups.py b/tests/test_groups.py index c021c7e..c10960d 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -13,3 +13,79 @@ assert (not handler.has_warning('A warning')) assert handler.has_error('An error') assert handler.records[0].extra['foo'] == 'bar' + + +def test_group_disabled(): + group = logbook.LoggerGroup() + logger1 = logbook.Logger('testlogger1') + logger2 = logbook.Logger('testlogger2') + + group.add_logger(logger1) + group.add_logger(logger2) + + # Test group disable + + group.disable() + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert not handler.has_warnings + + # Test group enable + + group.enable() + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert handler.has_warning('Warning 1') + assert handler.has_warning('Warning 2') + + # Test group disabled, but logger explicitly enabled + + group.disable() + + logger1.enable() + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert handler.has_warning('Warning 1') + assert not handler.has_warning('Warning 2') + + # Logger 1 will be enabled by using force=True + + group.disable(force=True) + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert not handler.has_warning('Warning 1') + assert not handler.has_warning('Warning 2') + + # Enabling without force means logger 1 will still be disabled. + + group.enable() + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert not handler.has_warning('Warning 1') + assert handler.has_warning('Warning 2') + + # Force logger 1 enabled. + + group.enable(force=True) + + with logbook.TestHandler() as handler: + logger1.warn('Warning 1') + logger2.warn('Warning 2') + + assert handler.has_warning('Warning 1') + assert handler.has_warning('Warning 2') diff --git a/tests/test_log_record.py b/tests/test_log_record.py index 0fb6ad6..1c52fe6 100644 --- a/tests/test_log_record.py +++ b/tests/test_log_record.py @@ -25,7 +25,6 @@ record.extra['existing'] = 'foo' assert record.extra['nonexisting'] == '' assert record.extra['existing'] == 'foo' - assert repr(record.extra) == "ExtraDict({'existing': 'foo'})" def test_calling_frame(active_handler, logger): diff --git a/tests/test_logger.py b/tests/test_logger.py index 8b6a9fd..9ac5ab8 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,4 +1,5 @@ import logbook +import pytest def test_level_properties(logger): @@ -26,3 +27,18 @@ assert logger.level_name == 'CRITICAL' group.remove_logger(logger) assert logger.group is None + + +def test_disabled_property(): + class MyLogger(logbook.Logger): + @property + def disabled(self): + return True + + logger = MyLogger() + + with pytest.raises(AttributeError): + logger.enable() + + with pytest.raises(AttributeError): + logger.disable() diff --git a/tests/test_logging_compat.py b/tests/test_logging_compat.py index 48dfebe..31fdd40 100644 --- a/tests/test_logging_compat.py +++ b/tests/test_logging_compat.py @@ -36,7 +36,10 @@ logger.warn('This is from the old %s', 'system') logger.error('This is from the old system') logger.critical('This is from the old system') + logger.error('This is a %(what)s %(where)s', {'what': 'mapping', 'where': 'test'}) assert ('WARNING: %s: This is from the old system' % + name) in captured.getvalue() + assert ('ERROR: %s: This is a mapping test' % name) in captured.getvalue() if set_root_logger_level: assert handler.records[0].level == logbook.DEBUG diff --git a/tests/test_logging_times.py b/tests/test_logging_times.py index f9c44e6..c87c4e8 100644 --- a/tests/test_logging_times.py +++ b/tests/test_logging_times.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta, tzinfo import logbook @@ -45,3 +45,51 @@ assert ratio > 0.99 assert ratio < 1.01 + + +def test_tz_aware(activation_strategy, logger): + """ + tests logbook.set_datetime_format() with a time zone aware time factory + """ + class utc(tzinfo): + def tzname(self, dt): + return 'UTC' + def utcoffset(self, dt): + return timedelta(seconds=0) + def dst(self, dt): + return timedelta(seconds=0) + + utc = utc() + + def utc_tz(): + return datetime.now(tz=utc) + + FORMAT_STRING = '{record.time:%H:%M:%S.%f%z} {record.message}' + handler = logbook.TestHandler(format_string=FORMAT_STRING) + with activation_strategy(handler): + logbook.set_datetime_format(utc_tz) + try: + logger.warn('this is a warning.') + record = handler.records[0] + finally: + # put back the default time factory + logbook.set_datetime_format('utc') + + assert record.time.tzinfo is not None + + +def test_invalid_time_factory(): + """ + tests logbook.set_datetime_format() with an invalid time factory callable + """ + def invalid_factory(): + return False + + with pytest.raises(ValueError) as e: + try: + logbook.set_datetime_format(invalid_factory) + finally: + # put back the default time factory + logbook.set_datetime_format('utc') + + assert 'Invalid callable value' in str(e.value) diff --git a/tests/test_mail_handler.py b/tests/test_mail_handler.py index babc4e2..fd7730b 100644 --- a/tests/test_mail_handler.py +++ b/tests/test_mail_handler.py @@ -6,6 +6,11 @@ from logbook.helpers import u from .utils import capturing_stderr_context, make_fake_mail_handler + +try: + from unittest.mock import Mock, call, patch +except ImportError: + from mock import Mock, call, patch __file_without_pyc__ = __file__ if __file_without_pyc__.endswith('.pyc'): @@ -104,3 +109,126 @@ assert len(related) == 2 assert re.search('Message type:\s+WARNING', related[0]) assert re.search('Message type:\s+DEBUG', related[1]) + + +def test_mail_handler_arguments(): + with patch('smtplib.SMTP', autospec=True) as mock_smtp: + + # Test the mail handler with supported arguments before changes to + # secure, credentials, and starttls + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr=('server.example.com', 465), + credentials=('username', 'password'), + secure=('keyfile', 'certfile')) + + mail_handler.get_connection() + + assert mock_smtp.call_args == call('server.example.com', 465) + assert mock_smtp.method_calls[1] == call().starttls( + keyfile='keyfile', certfile='certfile') + assert mock_smtp.method_calls[3] == call().login('username', 'password') + + # Test secure=() + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr=('server.example.com', 465), + credentials=('username', 'password'), + secure=()) + + mail_handler.get_connection() + + assert mock_smtp.call_args == call('server.example.com', 465) + assert mock_smtp.method_calls[5] == call().starttls( + certfile=None, keyfile=None) + assert mock_smtp.method_calls[7] == call().login('username', 'password') + + # Test implicit port with string server_addr, dictionary credentials, + # dictionary secure. + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr='server.example.com', + credentials={'user': 'username', 'password': 'password'}, + secure={'certfile': 'certfile2', 'keyfile': 'keyfile2'}) + + mail_handler.get_connection() + + assert mock_smtp.call_args == call('server.example.com', 465) + assert mock_smtp.method_calls[9] == call().starttls( + certfile='certfile2', keyfile='keyfile2') + assert mock_smtp.method_calls[11] == call().login( + user='username', password='password') + + # Test secure=True + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr=('server.example.com', 465), + credentials=('username', 'password'), + secure=True) + + mail_handler.get_connection() + + assert mock_smtp.call_args == call('server.example.com', 465) + assert mock_smtp.method_calls[13] == call().starttls( + certfile=None, keyfile=None) + assert mock_smtp.method_calls[15] == call().login('username', 'password') + assert len(mock_smtp.method_calls) == 16 + + # Test secure=False + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr=('server.example.com', 465), + credentials=('username', 'password'), + secure=False) + + mail_handler.get_connection() + + # starttls not called because we check len of method_calls before and + # after this test. + assert mock_smtp.call_args == call('server.example.com', 465) + assert mock_smtp.method_calls[16] == call().login('username', 'password') + assert len(mock_smtp.method_calls) == 17 + + with patch('smtplib.SMTP_SSL', autospec=True) as mock_smtp_ssl: + # Test starttls=False + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr='server.example.com', + credentials={'user': 'username', 'password': 'password'}, + secure={'certfile': 'certfile', 'keyfile': 'keyfile'}, + starttls=False) + + mail_handler.get_connection() + + assert mock_smtp_ssl.call_args == call( + 'server.example.com', 465, keyfile='keyfile', certfile='certfile') + assert mock_smtp_ssl.method_calls[0] == call().login( + user='username', password='password') + + # Test starttls=False with secure=True + mail_handler = logbook.MailHandler( + from_addr='from@example.com', + recipients='to@example.com', + server_addr='server.example.com', + credentials={'user': 'username', 'password': 'password'}, + secure=True, + starttls=False) + + mail_handler.get_connection() + + assert mock_smtp_ssl.call_args == call( + 'server.example.com', 465, keyfile=None, certfile=None) + assert mock_smtp_ssl.method_calls[1] == call().login( + user='username', password='password') + + + + + + diff --git a/tests/test_more.py b/tests/test_more.py index 597b80f..0d871d5 100644 --- a/tests/test_more.py +++ b/tests/test_more.py @@ -31,11 +31,16 @@ from logbook.more import ColorizedStderrHandler class TestColorizingHandler(ColorizedStderrHandler): - - def should_colorize(self, record): - return True - stream = StringIO() + def __init__(self, *args, **kwargs): + super(TestColorizingHandler, self).__init__(*args, **kwargs) + self._obj_stream = StringIO() + + @property + def stream(self): + return self._obj_stream + with TestColorizingHandler(format_string='{record.message}') as handler: + handler.force_color() logger.error('An error') logger.warn('A warning') logger.debug('A debug message') @@ -44,6 +49,15 @@ '\x1b[31;01mAn error\x1b[39;49;00m', '\x1b[33;01mA warning\x1b[39;49;00m', '\x1b[37mA debug message\x1b[39;49;00m'] + + with TestColorizingHandler(format_string='{record.message}') as handler: + handler.forbid_color() + logger.error('An error') + logger.warn('A warning') + logger.debug('A debug message') + lines = handler.stream.getvalue().rstrip('\n').splitlines() + assert lines == ['An error', 'A warning', 'A debug message'] + def test_tagged(default_handler): @@ -145,3 +159,54 @@ assert 2 == len(test_handler.records) assert 'message repeated 2 times: foo' in test_handler.records[0].message assert 'message repeated 1 times: bar' in test_handler.records[1].message + + +class TestRiemannHandler(object): + + @require_module("riemann_client") + def test_happy_path(self, logger): + from logbook.more import RiemannHandler + riemann_handler = RiemannHandler("127.0.0.1", 5555, message_type="test", level=logbook.INFO) + null_handler = logbook.NullHandler() + with null_handler.applicationbound(): + with riemann_handler: + logger.error("Something bad has happened") + try: + raise RuntimeError("For example, a RuntimeError") + except Exception as ex: + logger.exception(ex) + logger.info("But now it is ok") + + q = riemann_handler.queue + assert len(q) == 3 + error_event = q[0] + assert error_event["state"] == "error" + exc_event = q[1] + assert exc_event["description"] == "For example, a RuntimeError" + info_event = q[2] + assert info_event["state"] == "ok" + + @require_module("riemann_client") + def test_incorrect_type(self): + from logbook.more import RiemannHandler + with pytest.raises(RuntimeError): + RiemannHandler("127.0.0.1", 5555, message_type="fancy_type") + + @require_module("riemann_client") + def test_flush(self, logger): + from logbook.more import RiemannHandler + riemann_handler = RiemannHandler("127.0.0.1", + 5555, + message_type="test", + flush_threshold=2, + level=logbook.INFO) + null_handler = logbook.NullHandler() + with null_handler.applicationbound(): + with riemann_handler: + logger.info("Msg #1") + logger.info("Msg #2") + logger.info("Msg #3") + + q = riemann_handler.queue + assert len(q) == 1 + assert q[0]["description"] == "Msg #3" diff --git a/tests/test_nteventlog_handler.py b/tests/test_nteventlog_handler.py new file mode 100644 index 0000000..9c27beb --- /dev/null +++ b/tests/test_nteventlog_handler.py @@ -0,0 +1,52 @@ +import os + +import logbook +import pytest + +from .utils import require_module + + +@require_module('win32con') +@require_module('win32evtlog') +@require_module('win32evtlogutil') +@pytest.mark.skipif(os.environ.get('ENABLE_LOGBOOK_NTEVENTLOG_TESTS') is None, + reason="Don't clutter NT Event Log unless enabled.") +def test_nteventlog_handler(): + from win32con import ( + EVENTLOG_ERROR_TYPE, EVENTLOG_INFORMATION_TYPE, EVENTLOG_WARNING_TYPE) + from win32evtlog import ( + EVENTLOG_BACKWARDS_READ, EVENTLOG_SEQUENTIAL_READ, OpenEventLog, + ReadEventLog) + from win32evtlogutil import SafeFormatMessage + + logger = logbook.Logger('Test Logger') + + with logbook.NTEventLogHandler('Logbook Test Suite'): + logger.info('The info log message.') + logger.warning('The warning log message.') + logger.error('The error log message.') + + def iter_event_log(handle, flags, offset): + while True: + events = ReadEventLog(handle, flags, offset) + for event in events: + yield event + if not events: + break + + handle = OpenEventLog(None, 'Application') + flags = EVENTLOG_BACKWARDS_READ | EVENTLOG_SEQUENTIAL_READ + + for event in iter_event_log(handle, flags, 0): + source = str(event.SourceName) + if source == 'Logbook Test Suite': + message = SafeFormatMessage(event, 'Application') + if 'Message Level: INFO' in message: + assert 'The info log message' in message + assert event.EventType == EVENTLOG_INFORMATION_TYPE + if 'Message Level: WARNING' in message: + assert 'The warning log message' in message + assert event.EventType == EVENTLOG_WARNING_TYPE + if 'Message Level: ERROR' in message: + assert 'The error log message' in message + assert event.EventType == EVENTLOG_ERROR_TYPE diff --git a/tests/test_ticketing.py b/tests/test_ticketing.py index eaa241b..b203cb6 100644 --- a/tests/test_ticketing.py +++ b/tests/test_ticketing.py @@ -1,10 +1,13 @@ import os +import sys + try: from thread import get_ident except ImportError: from _thread import get_ident import logbook +import pytest from logbook.helpers import xrange from .utils import require_module @@ -13,7 +16,12 @@ if __file_without_pyc__.endswith(".pyc"): __file_without_pyc__ = __file_without_pyc__[:-1] +python_version = sys.version_info[:2] + +@pytest.mark.xfail( + os.name == 'nt' and (python_version == (3, 2) or python_version == (3, 3)), + reason='Problem with in-memory sqlite on Python 3.2, 3.3 and Windows') @require_module('sqlalchemy') def test_basic_ticketing(logger): from logbook.ticketing import TicketingHandler @@ -21,9 +29,9 @@ with TicketingHandler('sqlite:///') as handler: for x in xrange(5): logger.warn('A warning') - sleep(0.1) + sleep(0.2) logger.info('An error') - sleep(0.1) + sleep(0.2) if x < 2: try: 1 / 0 diff --git a/tests/test_utils.py b/tests/test_utils.py index 3d1443f..f4ca5b8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,21 +8,57 @@ _THRESHOLD = 0.1 - -def test_logged_if_slow_reached(logger, test_handler): +try: + from unittest.mock import Mock, call +except ImportError: + from mock import Mock, call + + +def test_logged_if_slow_reached(test_handler): with test_handler.applicationbound(): with logged_if_slow('checking...', threshold=_THRESHOLD): - sleep(2*_THRESHOLD) + sleep(2 * _THRESHOLD) assert len(test_handler.records) == 1 [record] = test_handler.records assert record.message == 'checking...' -def test_logged_if_slow_did_not_reached(logger, test_handler): +def test_logged_if_slow_did_not_reached(test_handler): with test_handler.applicationbound(): with logged_if_slow('checking...', threshold=_THRESHOLD): - sleep(_THRESHOLD/2) + sleep(_THRESHOLD / 2) assert len(test_handler.records) == 0 + + +def test_logged_if_slow_logger(): + logger = Mock() + + with logged_if_slow('checking...', threshold=_THRESHOLD, logger=logger): + sleep(2 * _THRESHOLD) + + assert logger.log.call_args == call(logbook.DEBUG, 'checking...') + + +def test_logged_if_slow_level(test_handler): + with test_handler.applicationbound(): + with logged_if_slow('checking...', threshold=_THRESHOLD, + level=logbook.WARNING): + sleep(2 * _THRESHOLD) + + assert test_handler.records[0].level == logbook.WARNING + + +def test_logged_if_slow_deprecated(logger, test_handler): + with test_handler.applicationbound(): + with logged_if_slow('checking...', threshold=_THRESHOLD, + func=logbook.error): + sleep(2 * _THRESHOLD) + + assert test_handler.records[0].level == logbook.ERROR + assert test_handler.records[0].message == 'checking...' + + with pytest.raises(TypeError): + logged_if_slow('checking...', logger=logger, func=logger.error) def test_deprecated_func_called(capture): diff --git a/tests/utils.py b/tests/utils.py index 281b299..d014cca 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,6 +7,7 @@ :license: BSD, see LICENSE for more details. """ import functools +import os import sys from contextlib import contextmanager @@ -30,6 +31,12 @@ require_py3 = pytest.mark.skipif( sys.version_info[0] < 3, reason="Requires Python 3") + +appveyor = pytest.mark.skipif( + os.environ.get('APPVEYOR') != 'True', reason='AppVeyor CI test') + +travis = pytest.mark.skipif( + os.environ.get('TRAVIS') != 'true', reason='Travis CI test') def require_module(module_name): diff --git a/testwin32log.py b/testwin32log.py deleted file mode 100644 index 47d889a..0000000 --- a/testwin32log.py +++ /dev/null @@ -1,7 +0,0 @@ -from logbook import NTEventLogHandler, Logger - -logger = Logger('MyLogger') -handler = NTEventLogHandler('My Application') - -with handler.applicationbound(): - logger.error('Testing') diff --git a/tox.ini b/tox.ini index b5183a1..ea70112 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,12 @@ [tox] -envlist=py26,py27,py32,py33,py34,py35,pypy,docs +envlist=py27,py34,py35,py36,pypy,docs skipsdist=True [testenv] whitelist_externals= rm deps= + py{27}: mock pytest Cython changedir={toxinidir} @@ -18,6 +19,7 @@ [testenv:pypy] deps= + mock pytest commands= {envpython} {toxinidir}/setup.py develop