diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b3e5537 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + open-pull-requests-limit: 1024 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a8be302..fa3a945 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,13 +27,15 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" + cache: pip + allow-prereleases: true - name: "Install dependencies" run: | set -xe @@ -52,18 +54,20 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] arch: ["x86", "x64"] env: ENABLE_LOGBOOK_NTEVENTLOG_TESTS: "1" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" architecture: "${{ matrix.arch }}" + cache: pip + allow-prereleases: true - run: python -VV - run: python -m site @@ -80,14 +84,16 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" architecture: "${{ matrix.arch }}" + cache: pip + allow-prereleases: true - name: "Install dependencies" run: | set -xe @@ -102,6 +108,6 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 852959c..68f567c 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -29,7 +29,7 @@ jobs: id-token: write steps: - name: Download artifacts - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0 + uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e # v2.28.0 with: path: artifacts/ run_id: ${{ github.event.inputs.run_id || github.event.workflow_run.id }} @@ -42,13 +42,13 @@ jobs: mv artifacts/wheels/*.whl dist/ - name: Publish to pypi.org - uses: pypa/gh-action-pypi-publish@f8c70e705ffc13c3b4d1221169b84f12a75d6ca8 # v1.8.8 + uses: pypa/gh-action-pypi-publish@b7f401de30cb6434a1e19f805ff006643653240e # v1.8.10 if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'pypi') with: packages-dir: dist/ - name: Publish to test.pypi.org - uses: pypa/gh-action-pypi-publish@f8c70e705ffc13c3b4d1221169b84f12a75d6ca8 # v1.8.8 + uses: pypa/gh-action-pypi-publish@b7f401de30cb6434a1e19f805ff006643653240e # v1.8.10 if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'testpypi' with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/wheel-builder.yml b/.github/workflows/wheel-builder.yml index 0107404..9061e7e 100644 --- a/.github/workflows/wheel-builder.yml +++ b/.github/workflows/wheel-builder.yml @@ -25,7 +25,7 @@ jobs: name: Build sdist runs-on: ubuntu-latest steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: # The tag to build or the tag received by the tag event ref: ${{ github.event.inputs.version || github.ref }} @@ -36,7 +36,7 @@ jobs: run: .venv/bin/pip install -U pip build - name: Make sdist run: .venv/bin/python -m build --sdist - - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: name: sdist path: dist/*.tar.gz @@ -50,16 +50,16 @@ jobs: os: [ubuntu-20.04, windows-2019, macos-11] steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 with: # The tag to build or the tag received by the tag event ref: ${{ github.event.inputs.version || github.ref }} persist-credentials: false - name: Build wheels - uses: pypa/cibuildwheel@f21bb8376a051ffb6cb5604b28ccaef7b90e8ab7 # v2.14.1 + uses: pypa/cibuildwheel@7da7df1efc530f07d1945c00934b8cfd34be0d50 # v2.16.1 - - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: name: wheels path: ./wheelhouse/*.whl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ee3839..9354901 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: rev: v3.7.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/timothycrosley/isort rev: 5.12.0 hooks: diff --git a/CHANGES b/CHANGES index 296cf89..1c07e24 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,16 @@ Logbook Changelog ================= +Version 1.7.0 +------------- + +Released on October 3rd, 2023 + +- Dropped support for Python 3.7 +- Passing (keyfile, certfile) to MailHandler's ``secure`` argument is deprecated + in favour of passing an ``ssl.SSLContext``. +- Python 3.12 support + Version 1.6.0 ------------- diff --git a/docs/conf.py b/docs/conf.py index 00f8cc9..5a4176b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,12 +3,7 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -import sys - -if sys.version_info < (3, 8): - from importlib_metadata import distribution -else: - from importlib.metadata import distribution +from importlib.metadata import distribution # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information diff --git a/pyproject.toml b/pyproject.toml index d6df1f3..e95f93a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,13 @@ maintainers = [ ] classifiers = [ "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dynamic = ["version"] [project.urls] @@ -40,7 +40,17 @@ jinja = ["Jinja2"] compression = ["brotli"] all = ["Logbook[execnet,sqlalchemy,redis,zmq,jinja,compression,nteventlog]"] nteventlog = ["pywin32; platform_system == 'Windows'"] -docs = ["Sphinx", "importlib_metadata; python_version < '3.8'"] +docs = ["Sphinx"] + +[tool.setuptools.dynamic] +version = { attr = "logbook.__version__" } + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a5e522f..0000000 --- a/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[metadata] -version = attr: logbook.__version__ - -[options] -packages = find: -package_dir = - =src -zip_safe = False - -[options.packages.find] -where = src diff --git a/src/logbook/__version__.py b/src/logbook/__version__.py index e4adfb8..14d9d2f 100644 --- a/src/logbook/__version__.py +++ b/src/logbook/__version__.py @@ -1 +1 @@ -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/src/logbook/base.py b/src/logbook/base.py index f9fb425..470577a 100644 --- a/src/logbook/base.py +++ b/src/logbook/base.py @@ -16,7 +16,12 @@ from weakref import ref as weakref from logbook.concurrency import greenlet_get_ident, thread_get_ident, thread_get_name -from logbook.helpers import cached_property, parse_iso8601, to_safe_json +from logbook.helpers import ( + cached_property, + datetime_utcnow, + parse_iso8601, + to_safe_json, +) _has_speedups = False try: @@ -39,7 +44,7 @@ group_reflected_property, ) -_datetime_factory = datetime.utcnow +_datetime_factory = datetime_utcnow def set_datetime_format(datetime_format): @@ -94,7 +99,7 @@ def utc_tz(): """ global _datetime_factory if datetime_format == "utc": - _datetime_factory = datetime.utcnow + _datetime_factory = datetime_utcnow elif datetime_format == "local": _datetime_factory = datetime.now elif callable(datetime_format): @@ -142,13 +147,16 @@ def level_name_property(): the internal level attribute. """ - def _get_level_name(self): + @property + def level_name(self): + """The level as unicode string""" return get_level_name(self.level) - def _set_level_name(self, level): + @level_name.setter + def level_name(self, level): self.level = lookup_level(level) - return property(_get_level_name, _set_level_name, doc="The level as unicode string") + return level_name def lookup_level(level): diff --git a/src/logbook/handlers.py b/src/logbook/handlers.py index d39662b..287e3fc 100644 --- a/src/logbook/handlers.py +++ b/src/logbook/handlers.py @@ -13,9 +13,11 @@ import os import re import socket +import ssl import stat import sys import traceback +import warnings from collections import deque from collections.abc import Iterable, Mapping from datetime import datetime, timedelta @@ -40,7 +42,7 @@ lookup_level, ) from logbook.concurrency import new_fine_grained_lock -from logbook.helpers import rename +from logbook.helpers import datetime_utcnow, rename DEFAULT_FORMAT_STRING = ( "[{record.time:%Y-%m-%d %H:%M:%S.%f%z}] " @@ -379,16 +381,15 @@ class StringFormatter: def __init__(self, format_string): self.format_string = format_string - def _get_format_string(self): + @property + def format_string(self): return self._format_string - def _set_format_string(self, value): + @format_string.setter + def format_string(self, value): self._format_string = value self._formatter = value - format_string = property(_get_format_string, _set_format_string) - del _get_format_string, _set_format_string - def format_record(self, record, handler): try: return self._formatter.format(record=record, handler=handler) @@ -408,8 +409,7 @@ def format_exception(self, record): def __call__(self, record, handler): line = self.format_record(record, handler) - exc = self.format_exception(record) - if exc: + if exc := self.format_exception(record): line += "\n" + exc return line @@ -435,19 +435,18 @@ def __init__(self, format_string): #: string. self.format_string = format_string - def _get_format_string(self): + @property + def format_string(self): if isinstance(self.formatter, StringFormatter): return self.formatter.format_string - def _set_format_string(self, value): + @format_string.setter + def format_string(self, value): if value is None: self.formatter = None else: self.formatter = self.formatter_class(value) - format_string = property(_get_format_string, _set_format_string) - del _get_format_string, _set_format_string - class HashingHandlerMixin: """Mixin class for handlers that are hashing records.""" @@ -507,7 +506,7 @@ def check_delivery(self, record): try: allow_delivery = None suppression_count = old_count = 0 - first_count = now = datetime.utcnow() + first_count = now = datetime_utcnow() if hash in self._record_limits: last_count, suppression_count = self._record_limits[hash] @@ -765,8 +764,7 @@ def _open(self, mode=None): def write(self, item): if isinstance(item, str): item = item.encode(encoding=self.encoding) - ret = self._compressor.process(item) - if ret: + if ret := self._compressor.process(item): self.ensure_stream_is_open() self.stream.write(ret) super().flush() @@ -776,8 +774,7 @@ def should_flush(self): def flush(self): if self._compressor is not None: - ret = self._compressor.flush() - if ret: + if ret := self._compressor.flush(): self.ensure_stream_is_open() self.stream.write(ret) super().flush() @@ -1282,14 +1279,14 @@ class MailHandler(Handler, StringFormatterHandlerMixin, LimitingHandlerMixin): `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`. + `secure` should be an :class:`ssl.SSLContext` or boolean. Please read + :ref:`ssl-security` for best practices. For backwards + compatibility reasons, `secure` may also be a tuple or dictionary, although + this is deprecated: + * ``(keyfile, certfile)`` tuple + * ``{'keyfile': keyfile, 'certfile': certfile}`` dict + * ``()`` an empty tuple is equivalent to ``True``. .. versionchanged:: 0.3 The handler supports the batching system now. @@ -1305,7 +1302,11 @@ class MailHandler(Handler, StringFormatterHandlerMixin, LimitingHandlerMixin): `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. + `secure` can now be a dictionary or boolean in addition to a tuple. + + .. versionchanged:: 1.7 + `secure` may be an :class:`ssl.SSLContext` (recommended). The tuple or + dict form is deprecated. """ default_format_string = MAIL_FORMAT_STRING @@ -1347,27 +1348,54 @@ def __init__( self.subject = subject self.server_addr = server_addr self.credentials = credentials - self.secure = secure + self.secure = self._adapt_secure(secure) 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): + def _adapt_secure(self, secure): + if secure is None or isinstance(secure, (bool, ssl.SSLContext)): + return secure + + if isinstance(secure, tuple): + if not secure: + # For backwards compatibility, () translates to True + return True + else: + keyfile, certfile = secure + elif isinstance(secure, Mapping): + keyfile = secure.get("keyfile", None) + certfile = secure.get("certfile", None) + else: + raise TypeError(f"Unexpected type for `secure`: {type(secure)}") + + warnings.warn( + "Passing keyfile and certfile are deprecated, use an " + "SSLContext instead.", + DeprecationWarning, + stacklevel=3, + ) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.load_cert_chain(certfile, keyfile) + + return ctx + + @property + def related_format_string(self): if isinstance(self.related_formatter, StringFormatter): return self.related_formatter.format_string - def _set_related_format_string(self, value): + @related_format_string.setter + def related_format_string(self, value): if value is None: self.related_formatter = None else: self.related_formatter = self.formatter_class(value) - related_format_string = property( - _get_related_format_string, _set_related_format_string - ) - del _get_related_format_string, _set_related_format_string - def get_recipients(self, record): """Returns the recipients for a record. By default the :attr:`recipients` attribute is returned for all records. @@ -1452,9 +1480,17 @@ def get_connection(self): """ from smtplib import SMTP, SMTP_PORT, SMTP_SSL, SMTP_SSL_PORT + if self.secure: + if self.starttls: + default_port = 587 + else: + default_port = SMTP_SSL_PORT + else: + default_port = SMTP_PORT + if self.server_addr is None: host = "127.0.0.1" - port = self.secure and SMTP_SSL_PORT or SMTP_PORT + port = default_port else: try: host, port = self.server_addr @@ -1462,43 +1498,23 @@ def get_connection(self): # 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, Mapping): - keyfile = self.secure.get("keyfile", None) - certfile = self.secure.get("certfile", None) - elif isinstance(self.secure, Iterable): - # Allow empty tuple for backwards compatibility - if len(self.secure) == 0: - keyfile = certfile = None - else: - keyfile, certfile = self.secure + port = default_port + + if isinstance(self.secure, ssl.SSLContext): + context = self.secure else: - keyfile = certfile = None + context = 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) + if self.secure and not self.starttls: + con = SMTP_SSL(host, port, context=context) else: con = SMTP(host, port) - if self.credentials is not None: - secure = self.secure - if self.starttls and secure is not None and secure is not False: - con.ehlo() - con.starttls(keyfile=keyfile, certfile=certfile) - con.ehlo() + if self.secure and self.starttls: + con.starttls(context=context) + con.ehlo() + if self.credentials is not None: # Allow credentials to be a tuple or dict. if isinstance(self.credentials, Mapping): credentials_args = () @@ -1508,6 +1524,7 @@ def get_connection(self): credentials_kwargs = dict() con.login(*credentials_args, **credentials_kwargs) + return con def close_connection(self, con): diff --git a/src/logbook/helpers.py b/src/logbook/helpers.py index 233f2eb..eb58304 100644 --- a/src/logbook/helpers.py +++ b/src/logbook/helpers.py @@ -13,7 +13,7 @@ import re import sys import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone # this regexp also matches incompatible dates like 20070101 because # some libraries (like the python xmlrpclib modules) use this @@ -64,15 +64,14 @@ def _rename_atomic(src, dst): retry = 0 rv = False while not rv and retry < 100: - rv = _MoveFileTransacted( + if rv := _MoveFileTransacted( src, dst, None, None, _MOVEFILE_REPLACE_EXISTING | _MOVEFILE_WRITE_THROUGH, ta, - ) - if rv: + ): rv = _CommitTransaction(ta) break else: @@ -145,10 +144,23 @@ def _convert(obj): return _convert(data) +if sys.version_info >= (3, 12): + + def datetime_utcnow(): + """datetime.utcnow() but doesn't emit a deprecation warning. + + Will be fixed by https://github.com/getlogbook/logbook/issues/353 + """ + return datetime.now(timezone.utc).replace(tzinfo=None) + +else: + datetime_utcnow = datetime.utcnow + + def format_iso8601(d=None): """Returns a date in iso8601 format.""" if d is None: - d = datetime.utcnow() + d = datetime_utcnow() rv = d.strftime("%Y-%m-%dT%H:%M:%S") if d.microsecond: rv += "." + str(d.microsecond) diff --git a/src/logbook/more.py b/src/logbook/more.py index 4bff87b..d9bb8d2 100644 --- a/src/logbook/more.py +++ b/src/logbook/more.py @@ -154,11 +154,6 @@ class TwitterHandler(Handler, StringFormatterHandlerMixin): """A handler that logs to twitter. Requires that you sign up an application on twitter and request xauth support. Furthermore the oauth2 library has to be installed. - - If you don't want to register your own application and request xauth - credentials, there are a couple of leaked consumer key and secret - pairs from application explicitly whitelisted at Twitter - (`leaked secrets `_). """ default_format_string = TWITTER_FORMAT_STRING @@ -383,8 +378,7 @@ def get_color(self, record): def format(self, record): rv = super().format(record) if self.should_colorize(record): - color = self.get_color(record) - if color: + if color := self.get_color(record): rv = colorize(color, rv) return rv diff --git a/src/logbook/queues.py b/src/logbook/queues.py index fecb35a..9d9454f 100644 --- a/src/logbook/queues.py +++ b/src/logbook/queues.py @@ -718,8 +718,7 @@ def _target(self): self.setup.push_thread() try: while self.running: - record = self.subscriber.recv() - if record: + if record := self.subscriber.recv(): try: self.queue.put(record, timeout=0.05) except Full: diff --git a/src/logbook/ticketing.py b/src/logbook/ticketing.py index 510e0db..e463693 100644 --- a/src/logbook/ticketing.py +++ b/src/logbook/ticketing.py @@ -28,8 +28,7 @@ def __init__(self, db, row): @cached_property def last_occurrence(self): """The last occurrence.""" - rv = self.get_occurrences(limit=1) - if rv: + if rv := self.get_occurrences(limit=1): return rv[0] def get_occurrences(self, order_by="-time", limit=50, offset=0): @@ -452,8 +451,7 @@ def delete_ticket(self, ticket_id): def get_ticket(self, ticket_id): """Return a single ticket with all occurrences.""" - ticket = self.database.tickets.find_one({"_id": self._oid(ticket_id)}) - if ticket: + if ticket := self.database.tickets.find_one({"_id": self._oid(ticket_id)}): return Ticket(self, ticket) def get_occurrences(self, ticket, order_by="-time", limit=50, offset=0): diff --git a/src/logbook/utils.py b/src/logbook/utils.py index db79f25..81f7b43 100644 --- a/src/logbook/utils.py +++ b/src/logbook/utils.py @@ -146,8 +146,7 @@ def __name__(self): @property def __doc__(self): - returned = self._get_underlying_func().__doc__ - if returned: # pylint: disable=no-member + if returned := self._get_underlying_func().__doc__: # pylint: disable=no-member returned += "\n.. deprecated\n" # pylint: disable=no-member if self._message: returned += f" {self._message}" # pylint: disable=no-member diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d136728..21ae55f 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -16,9 +16,10 @@ async def task(handler, msg): await asyncio.sleep(0) # allow for context switch - asyncio.get_event_loop().run_until_complete( - asyncio.gather(task(h1, "task1"), task(h2, "task2")) - ) + async def main(): + await asyncio.gather(task(h1, "task1"), task(h2, "task2")) + + asyncio.run(main()) assert len(h1.records) == ITERATIONS assert all(["task1" == r.msg for r in h1.records]) diff --git a/tests/test_logging_compat.py b/tests/test_logging_compat.py index 74f08fa..18cbf09 100644 --- a/tests/test_logging_compat.py +++ b/tests/test_logging_compat.py @@ -1,4 +1,6 @@ import functools +import re +import warnings from io import StringIO from random import randrange @@ -77,16 +79,11 @@ def test_warning_redirections(): from logbook.compat import redirected_warnings with logbook.TestHandler() as handler: - redirector = redirected_warnings() - redirector.start() - try: - from warnings import resetwarnings, warn + with redirected_warnings(): + warnings.warn( + RuntimeWarning(f"Testing {next(test_warning_redirections_i)}") + ) - resetwarnings() - warn(RuntimeWarning("Testing" + str(next(test_warning_redirections_i)))) - finally: - redirector.end() - - assert len(handler.records) == 1 + assert len(handler.formatted_records) == 1 assert handler.formatted_records[0].startswith("[WARNING] RuntimeWarning: Testing") assert __file_without_pyc__ in handler.records[0].filename diff --git a/tests/test_mail_handler.py b/tests/test_mail_handler.py index 108ef00..550503c 100644 --- a/tests/test_mail_handler.py +++ b/tests/test_mail_handler.py @@ -1,6 +1,7 @@ import base64 import re -from unittest.mock import call, patch +import ssl +from unittest.mock import ANY, call, patch import logbook @@ -103,130 +104,245 @@ def test_group_handler_mail_combo(activation_strategy, logger): 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" - ) + patch_smtp = patch("smtplib.SMTP", autospec=True) + patch_load_cert_chain = patch("ssl.SSLContext.load_cert_chain", autospec=True) + + with patch_load_cert_chain as mock_load_cert_chain: + with patch_smtp 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() + + mock_smtp.assert_called_once_with("server.example.com", 465) + mock_smtp().starttls.assert_called_once_with(context=ANY) + assert isinstance( + mock_smtp().starttls.call_args.kwargs["context"], ssl.SSLContext + ) + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_called_once_with("certfile", "keyfile") + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # 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() + + mock_smtp.assert_called_once_with("server.example.com", 465) + mock_smtp().starttls.assert_called_once_with(context=None) + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # 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() + + mock_smtp.assert_called_once_with("server.example.com", 587) + mock_smtp().starttls.assert_called_once_with(context=ANY) + assert isinstance( + mock_smtp().starttls.call_args.kwargs["context"], ssl.SSLContext + ) + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_called_once_with("certfile2", "keyfile2") + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test default port for non-secure connection + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + server_addr="server.example.com", + ) + + mail_handler.get_connection() + + mock_smtp.assert_called_once_with("server.example.com", 25) + mock_smtp().starttls.assert_not_called() + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test default host and port for non-secure connection + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + ) + + mail_handler.get_connection() + + mock_smtp.assert_called_once_with("127.0.0.1", 25) + mock_smtp().starttls.assert_not_called() + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test default host and port for starttls connection + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + secure=True, + ) + + mail_handler.get_connection() + + mock_smtp.assert_called_once_with("127.0.0.1", 587) + mock_smtp().starttls.assert_called_once_with(context=None) + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # 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() + + mock_smtp.assert_called_once_with("server.example.com", 465) + mock_smtp().starttls.assert_called_once_with(context=None) + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # 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() + + mock_smtp.assert_called_once_with("server.example.com", 465) + mock_smtp().starttls.assert_not_called() + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() + mock_smtp.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test SSLContext + context = ssl.create_default_context() + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + server_addr=("server.example.com", 465), + credentials=("username", "password"), + secure=context, + ) + + mail_handler.get_connection() + + mock_smtp.assert_called_once_with("server.example.com", 465) + mock_smtp().starttls.assert_called_once_with(context=context) + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() + + 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() + + mock_smtp_ssl.assert_called_once_with( + "server.example.com", 465, context=ANY + ) + assert isinstance(mock_smtp_ssl.call_args.kwargs["context"], ssl.SSLContext) + mock_load_cert_chain.assert_called_once_with("certfile", "keyfile") + mock_smtp().login.assert_called_once_with("username", "password") + mock_smtp_ssl.reset_mock() + mock_load_cert_chain.reset_mock() + + # 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() + + mock_smtp_ssl.assert_called_once_with( + "server.example.com", 465, context=None + ) + mock_smtp_ssl().starttls.assert_not_called() + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() + mock_smtp_ssl.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test default host and port for starttls connection + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + secure=True, + starttls=False, + ) + + mail_handler.get_connection() + + mock_smtp_ssl.assert_called_once_with("127.0.0.1", 465, context=None) + mock_smtp_ssl().starttls.assert_not_called() + mock_load_cert_chain.assert_not_called() + mock_smtp_ssl.reset_mock() + mock_load_cert_chain.reset_mock() + + # Test SSLContext + context = ssl.create_default_context() + mail_handler = logbook.MailHandler( + from_addr="from@example.com", + recipients="to@example.com", + server_addr="server.example.com", + credentials={"user": "username", "password": "password"}, + secure=context, + starttls=False, + ) + + mail_handler.get_connection() + + mock_smtp_ssl.assert_called_once_with( + "server.example.com", 465, context=context + ) + mock_smtp_ssl().starttls.assert_not_called() + mock_smtp().login.assert_called_once_with("username", "password") + mock_load_cert_chain.assert_not_called() diff --git a/tox.ini b/tox.ini index 4073795..499ed52 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{37,38,39,310,311}{,-nospeedups},pypy,docs +envlist = py{38,39,310,311,312}{,-nospeedups},pypy,docs [testenv] extras = @@ -27,8 +27,8 @@ commands = [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 3.11: py311, docs + 3.12: py312