From 3f0032ff43176c88ec5911829d75b667ce0385f2 Mon Sep 17 00:00:00 2001 From: Jason Madden Date: Tue, 12 Nov 2024 07:50:16 -0600 Subject: [PATCH] Update supported python versions; use native namespace package; lint --- .coveragerc | 2 + .github/dependabot.yml | 13 ++ .github/workflows/tests.yml | 62 +++-- .pylintrc | 220 ++++++++++++++++++ .readthedocs.yml | 37 +++ CHANGES.rst | 5 +- INSTALL | 0 Jenkinsfile | 3 - TODO | 0 babel.cfg | 7 - doc-requirements.txt | 1 - docs/conf.py | 77 +++--- nose2.cfg | 20 -- pyproject.toml | 6 + setup.cfg | 8 - setup.py | 29 +-- src/nti/__init__.py | 2 +- src/nti/mailer/_default_template_mailer.py | 25 +- src/nti/mailer/_verp.py | 11 +- src/nti/mailer/interfaces.py | 13 +- src/nti/mailer/queue.py | 6 +- .../tests/test_default_template_mailer.py | 57 +++-- src/nti/mailer/tests/test_interfaces.py | 2 +- src/nti/mailer/tests/test_queue.py | 16 +- src/nti/mailer/tests/test_verp.py | 32 +-- 25 files changed, 451 insertions(+), 203 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .pylintrc create mode 100644 .readthedocs.yml delete mode 100644 INSTALL delete mode 100644 Jenkinsfile delete mode 100644 TODO delete mode 100644 babel.cfg delete mode 100644 doc-requirements.txt delete mode 100644 nose2.cfg create mode 100644 pyproject.toml delete mode 100644 setup.cfg diff --git a/.coveragerc b/.coveragerc index f425380..7cf1a01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,3 +19,5 @@ exclude_lines = raise AssertionError Python 2 if __name__ == .__main__.: +precision = 2 +fail_under = 100 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b84a24 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: monthly diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d0f0e6..6306898 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,60 +5,50 @@ on: [push, pull_request] env: PYTHONHASHSEED: 1042466059 ZOPE_INTERFACE_STRICT_IRO: 1 - # The libuv loop has much better behaved stat - # watchers (they respond faster). - GEVENT_LOOP: libuv - PYTHONDEVMODE: 1 - PYTHONUNBUFFERED: 1 - PYTHONFAULTHANDLER: 1 jobs: test: strategy: matrix: - python-version: [2.7, pypy2, pypy3, 3.6, 3.7, 3.8, 3.9] + python-version: + - "pypy-3.10" + - "3.11" + - "3.12" + - "3.13" + extras: + - "[test,docs]" + # include: + # - python-version: "3.13" + # extras: "[test,docs,gevent,pyramid]" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Pip cache - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{hashFiles('setup.*') }} - - name: Install system dependencies (ubuntu) - if: startsWith(runner.os, 'Linux') - # for building lxml, typically on PyPy - run: | - sudo apt-get install -y libxml2-dev libxslt-dev + cache: 'pip' + cache-dependency-path: 'setup.py' - name: Install dependencies - # Fudge needs 2to3 at install time, which went away in - # setuptools 58. run: | - python -m pip install -U pip "setuptools < 58" wheel + python -m pip install -U pip setuptools wheel python -m pip install -U coverage - python -m pip install -U -e ".[test,docs]" - python -m pip install -q -U 'faulthandler; python_version == "2.7" and platform_python_implementation == "CPython"' - - name: Test With Coverage - if: matrix.python-version != 'pypy2' && matrix.python-version != 'pypy3' + python -m pip install -v -U -e ".${{ matrix.extras }}" + - name: Test run: | - coverage run -m zope.testrunner --test-path=src --auto-color -vvv + python -m coverage run -m zope.testrunner --test-path=src --auto-color --auto-progress coverage run -a -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctests - coverage report -i --fail-under=100 - - name: Test Without Coverage - if: startsWith(matrix.python-version, 'pypy') + coverage combine || true + coverage report -i || true + - name: Lint + if: matrix.python-version == '3.12' run: | - python -m zope.testrunner --test-path=src --auto-color -vvv - python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctests + python -m pip install -U pylint + pylint src - name: Submit to Coveralls - # This is a container action, which only runs on Linux. - if: matrix.python-version != 'pypy2' && matrix.python-version != 'pypy3' - uses: AndreMiras/coveralls-python-action@develop + uses: coverallsapp/github-action@v2 with: parallel: true @@ -67,6 +57,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Coveralls Finished - uses: AndreMiras/coveralls-python-action@develop + uses: coverallsapp/github-action@v2 with: parallel-finished: true diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..814d8a3 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,220 @@ +[MASTER] +load-plugins=pylint.extensions.bad_builtin, + pylint.extensions.check_elif, + pylint.extensions.code_style, + pylint.extensions.dict_init_mutate, + pylint.extensions.docstyle, + pylint.extensions.dunder, + pylint.extensions.comparison_placement, + pylint.extensions.confusing_elif, + pylint.extensions.for_any_all, + pylint.extensions.consider_refactoring_into_while_condition, + pylint.extensions.mccabe, + pylint.extensions.eq_without_hash, + pylint.extensions.redefined_variable_type, + pylint.extensions.overlapping_exceptions, + pylint.extensions.docparams, + pylint.extensions.private_import, + pylint.extensions.set_membership, + pylint.extensions.typing, + +# magic_value wants you to not use arbitrary strings and numbers +# inline in the code. But it's overzealous and has way too many false +# positives. Trust people to do the most readable thing. +# pylint.extensions.magic_value + +# Empty comment would be good, except it detects blank lines within +# a single comment block. +# +# Those are often used to separate paragraphs, like here. +# pylint.extensions.empty_comment, + +# consider_ternary_expression is a nice check, but is also overzealous. +# Trust the human to do the readable thing. +# pylint.extensions.consider_ternary_expression, + +# redefined_loop_name tends to catch us with things like +# for name in (a, b, c): name = name + '_column' ... +# pylint.extensions.redefined_loop_name, + +# This wants you to turn ``x in (1, 2)`` into ``x in {1, 2}``. +# They both result in the LOAD_CONST bytecode, one a tuple one a +# frozenset. In theory a set lookup using hashing is faster than +# a linear scan of a tuple; but if the tuple is small, it can often +# actually be faster to scan the tuple. +# pylint.extensions.set_membership, + +# Fix zope.cachedescriptors.property.Lazy; the property-classes doesn't seem to +# do anything. +# https://stackoverflow.com/questions/51160955/pylint-how-to-specify-a-self-defined-property-decorator-with-property-classes +# For releases prior to 2.14.2, this needs to be a one-line, quoted string. After that, +# a multi-line string. +# - Make zope.cachedescriptors.property.Lazy look like a property; +# fixes pylint thinking it is a method. +# - Run in Pure Python mode (ignore C extensions that respect this); +# fixes some issues with zope.interface, like IFoo.providedby(ob) +# claiming not to have the right number of parameters...except no, it does not. +init-hook = + import astroid.bases + astroid.bases.POSSIBLE_PROPERTIES.add('Lazy') + astroid.bases.POSSIBLE_PROPERTIES.add('LazyOnClass') + astroid.bases.POSSIBLE_PROPERTIES.add('readproperty') + astroid.bases.POSSIBLE_PROPERTIES.add('non_overridable') + import os + os.environ['PURE_PYTHON'] = ("1") + # Ending on a quoted string + # breaks pylint 2.14.5 (it strips the trailing quote. This is + # probably because it tries to handle one-line quoted strings as well as multi-blocks). + # The parens around it fix the issue. + + +[MESSAGES CONTROL] + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). +# NOTE: comments must go ABOVE the statement. In Python 2, mixing in +# comments disables all directives that follow, while in Python 3, putting +# comments at the end of the line does the same thing (though Py3 supports +# mixing) + + +# invalid-name, ; We get lots of these, especially in scripts. should fix many of them +# protected-access, ; We have many cases of this; legit ones need to be examinid and commented, then this removed +# no-self-use, ; common in superclasses with extension points +# too-few-public-methods, ; Exception and marker classes get tagged with this +# exec-used, ; should tag individual instances with this, there are some but not too many +# global-statement, ; should tag individual instances +# multiple-statements, ; "from gevent import monkey; monkey.patch_all()" +# locally-disabled, ; yes, we know we're doing this. don't replace one warning with another +# cyclic-import, ; most of these are deferred imports +# too-many-arguments, ; these are almost always because that's what the stdlib does +# redefined-builtin, ; likewise: these tend to be keyword arguments like len= in the stdlib +# undefined-all-variable, ; XXX: This crashes with pylint 1.5.4 on Travis (but not locally on Py2/3 +# ; or landscape.io on Py3). The file causing the problem is unclear. UPDATE: identified and disabled +# that file. +# see https://github.com/PyCQA/pylint/issues/846 +# useless-suppression: the only way to avoid repeating it for specific statements everywhere that we +# do Py2/Py3 stuff is to put it here. Sadly this means that we might get better but not realize it. +# duplicate-code: Yeah, the compatibility ssl modules are much the same +# In pylint 1.8.0, inconsistent-return-statements are created for the wrong reasons. +# This code raises it, even though there is only one return (the implicit ``return None`` is presumably +# what triggers it): +# def foo(): +# if baz: +# return 1 +# In Pylint 2dev1, needed for Python 3.7, we get spurious "useless return" errors: +# @property +# def foo(self): +# return None # generates useless-return +# Pylint 2.4 adds import-outside-toplevel. But we do that a lot to defer imports because of patching. +# Pylint 2.4 adds self-assigning-variable. But we do *that* to avoid unused-import when we +# "export" the variable and dont have a __all__. +# Pylint 2.6+ adds some python-3-only things that dont apply: raise-missing-from, super-with-arguments, consider-using-f-string, redundant-u-string-prefix +# cyclic import is added because it pylint is spuriously detecting that +# consider-using-assignment-expr wants you to transform things like: +# foo = get_foo() +# if foo: ... +# +# Into ``if (foo := get_foo()):`` +# But there are a *lot* of those. Trust people to do the right, most +# readable, thing +# +# docstring-first-line-empty: That's actually our standard, based on Django. +# XXX: unclear on the docstring warnings, missing-type-doc, missing-param-doc, +# differing-param-doc, differing-type-doc (are the last two replacements for the first two?) +# +# They should be addressed, in general they are a good thing, but sometimes they are +# unnecessary. +disable=wrong-import-position, + wrong-import-order, + missing-docstring, + ungrouped-imports, + invalid-name, + too-few-public-methods, + global-statement, + locally-disabled, + too-many-arguments, + useless-suppression, + duplicate-code, + useless-object-inheritance, + import-outside-toplevel, + self-assigning-variable, + consider-using-f-string, + consider-using-assignment-expr, + use-dict-literal, + missing-type-doc, + missing-param-doc, + differing-param-doc, + differing-type-doc, + compare-to-zero, + docstring-first-line-empty, + +enable=consider-using-augmented-assign + +[FORMAT] +max-line-length=100 +max-module-lines=1100 + +[MISCELLANEOUS] +# List of note tags to take in consideration, separated by a comma. +#notes=FIXME,XXX,TODO +# Disable that, we don't want them to fail the lint CI job. +notes= + +[VARIABLES] + +dummy-variables-rgx=_.* +init-import=true + +[TYPECHECK] + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldnt trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST,acl_users,aq_parent,providedBy + + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +# XXX: deprecated in 2.14; replaced with ignored-checks-for-mixins. +# The defaults for that value seem to be what we want +#ignore-mixin-members=yes + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). This can work +# with qualified names. +#ignored-classes=SSLContext, SSLSocket, greenlet, Greenlet, parent, dead + + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +#ignored-modules=gevent._corecffi,gevent.os,os,greenlet,threading,gevent.libev.corecffi,gevent.socket,gevent.core,gevent.testing.support +ignored-modules=psycopg2.errors + +[DESIGN] +max-attributes=12 +max-parents=10 +# Bump complexity up one. +max-complexity=11 + +[BASIC] +# Prospector turns ot unsafe-load-any-extension by default, but +# pylint leaves it off. This is the proximal cause of the +# undefined-all-variable crash. +unsafe-load-any-extension = yes +# This does not seem to work, hence the init-hook +property-classes=zope.cachedescriptors.property.Lazy,zope.cachedescriptors.property.Cached + +[CLASSES] +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. + + + +# Local Variables: +# mode: conf +# End: diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..17a626b --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,37 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Some things can only be configured on the RTD dashboard. +# Those that we may have changed from the default include: + +# Analytics code: +# Show Version Warning: False +# Single Version: True + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + builder: html + configuration: docs/conf.py + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + # You can also specify other tool versions: + # nodejs: "19" + # rust: "1.64" + # golang: "1.19" + +# Set the version of Python and requirements required to build your +# docs +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CHANGES.rst b/CHANGES.rst index ea3cd38..6fc26d4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,10 +2,11 @@ Changes ========= -0.0.1a3 (unreleased) +1.0.0 (unreleased) ==================== -- Nothing changed yet. +- Drop support for Python < 3.10. +- Use native namespace packages. 0.0.1a2 (2021-09-07) diff --git a/INSTALL b/INSTALL deleted file mode 100644 index e69de29..0000000 diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index a8d7380..0000000 --- a/Jenkinsfile +++ /dev/null @@ -1,3 +0,0 @@ -@Library("nti.javascript-modules") _ -buildoutPipeline { -} diff --git a/TODO b/TODO deleted file mode 100644 index e69de29..0000000 diff --git a/babel.cfg b/babel.cfg deleted file mode 100644 index e05e286..0000000 --- a/babel.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[lingua_python: **.py] - -[lingua_xml: **.pt] - -[extractors] -lingua_xml = nti.utils.babel:extract_xml -lingua_python = nti.utils.babel:extract_python diff --git a/doc-requirements.txt b/doc-requirements.txt deleted file mode 100644 index e9704b8..0000000 --- a/doc-requirements.txt +++ /dev/null @@ -1 +0,0 @@ -.[docs] diff --git a/docs/conf.py b/docs/conf.py index e9a7a7b..4762272 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -177,40 +177,59 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - "http://docs.pylonsproject.org/projects/pyramid/en/latest/": None, - #'https://ntiwref.readthedocs.io/en/latest': None, - 'http://docs.python.org/': None, - 'http://ntischema.readthedocs.io/en/latest/': None, - 'http://ntizodb.readthedocs.io/en/latest/': None, - 'http://persistent.readthedocs.io/en/latest': None, - 'http://zodb.readthedocs.io/en/latest': None, - - 'http://zopecomponent.readthedocs.io/en/latest': None, - 'http://zopecontainer.readthedocs.io/en/latest': None, - 'http://zopedatetime.readthedocs.io/en/latest': None, - 'http://zopedublincore.readthedocs.io/en/latest': None, - 'http://zopeevent.readthedocs.io/en/latest': None, - 'http://zopehookable.readthedocs.io/en/latest': None, - 'http://zopeinterface.readthedocs.io/en/latest': None, - 'http://zopeintid.readthedocs.io/en/latest/': None, - 'http://zopemimetype.readthedocs.io/en/latest/': None, - 'http://zopeproxy.readthedocs.io/en/latest': None, - 'http://zopeschema.readthedocs.io/en/latest/': None, - 'https://zopesecurity.readthedocs.io/en/latest/': None, - 'http://zopelifecycleevent.readthedocs.io/en/latest/': None, - - 'https://docs.pylonsproject.org/projects/pyramid_mailer/en/latest/': None, - 'https://repozesendmail.readthedocs.io/en/latest/': None, - 'https://boto3.amazonaws.com/v1/documentation/api/latest/': None, +# intersphinx_mapping = { +# "http://docs.pylonsproject.org/projects/pyramid/en/latest/": None, +# #'https://ntiwref.readthedocs.io/en/latest': None, +# 'http://docs.python.org/': None, +# 'http://ntischema.readthedocs.io/en/latest/': None, +# 'http://ntizodb.readthedocs.io/en/latest/': None, +# 'http://persistent.readthedocs.io/en/latest': None, +# 'http://zodb.readthedocs.io/en/latest': None, + +# 'http://zopecomponent.readthedocs.io/en/latest': None, +# 'http://zopecontainer.readthedocs.io/en/latest': None, +# 'http://zopedatetime.readthedocs.io/en/latest': None, +# 'http://zopedublincore.readthedocs.io/en/latest': None, +# 'http://zopeevent.readthedocs.io/en/latest': None, +# 'http://zopehookable.readthedocs.io/en/latest': None, +# 'http://zopeinterface.readthedocs.io/en/latest': None, +# 'http://zopeintid.readthedocs.io/en/latest/': None, +# 'http://zopemimetype.readthedocs.io/en/latest/': None, +# 'http://zopeproxy.readthedocs.io/en/latest': None, +# 'http://zopeschema.readthedocs.io/en/latest/': None, +# 'https://zopesecurity.readthedocs.io/en/latest/': None, +# 'http://zopelifecycleevent.readthedocs.io/en/latest/': None, + +# 'https://docs.pylonsproject.org/projects/pyramid_mailer/en/latest/': None, +# 'https://repozesendmail.readthedocs.io/en/latest/': None, +# 'https://boto3.amazonaws.com/v1/documentation/api/latest/': None, +# } + +intersphinx_mapping = { + 'component': ('https://zopecomponent.readthedocs.io/en/latest/', None), + 'container': ('https://zopecontainer.readthedocs.io/en/latest/', None,), + 'i18n': ('https://zopei18nmessageid.readthedocs.io/en/latest/', None), + 'interface': ('https://zopeinterface.readthedocs.io/en/latest/', None), + 'persistent': ('https://persistent.readthedocs.io/en/latest', None), + 'python': ('https://docs.python.org/', None), + 'schema': ('https://zopeschema.readthedocs.io/en/latest/', None), + 'site': ('https://zopesite.readthedocs.io/en/latest/', None,), + 'testing': ('https://ntitesting.readthedocs.io/en/latest/', None), + 'traversing': ('https://zopetraversing.readthedocs.io/en/latest/', None), + 'zodb': ('http://www.zodb.org/en/latest/', None), + 'external': ('https://ntiexternalization.readthedocs.io/en/latest/', None), + 'acquisition': ('https://acquisition.readthedocs.io/en/latest', None,), + 'zcintid': ('https://zcintid.readthedocs.io/en/latest', None), + 'zopeintid': ('https://zopeintid.readthedocs.io/en/latest', None,), + 'location': ('https://zopelocation.readthedocs.io/en/latest', None,), + 'btrees': ('https://btrees.readthedocs.io/en/latest', None) } - extlinks = { 'issue': ('https://github.com/NextThought/nti.mailer/issues/%s', - 'issue #'), + 'issue #%s'), 'pr': ('https://github.com/NextThought/nti.mailer/pull/%s', - 'pull request #')} + 'pull request #%s')} # Sphinx 1.8+ prefers this to `autodoc_default_flags`. It's documented that diff --git a/nose2.cfg b/nose2.cfg deleted file mode 100644 index 7dbcd5f..0000000 --- a/nose2.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[unittest] -plugins = nose2.plugins.layers - -[log-capture] -always-on = true -clear-handlers = true -loglevel = DEBUG - -[output-buffer] -# always-on = true -# stderr = true - -[doctest] -always-on = true -extensions = .txt - .rst - -[layer-reporter] -always-on = true -colors = true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d98f49f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools >= 40.8.0", + "wheel", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8818871..0000000 --- a/setup.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[nosetests] -cover-package=nti.mailer - -[aliases] -dev = develop easy_install nti.mailer[test] - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py index 04dff6d..75ba9c8 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ import codecs -from setuptools import setup, find_packages +from setuptools import setup +from setuptools import find_namespace_packages entry_points = { 'console_scripts': [ @@ -10,7 +11,6 @@ } TESTS_REQUIRE = [ - 'fudge', 'nti.testing', 'zope.testrunner', 'nti.app.pyramid-zope >= 0.0.3', @@ -44,23 +44,20 @@ def _read(fname): 'Operating System :: OS Independent', 'Development Status :: 3 - Alpha', 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], url="https://github.com/OpenNTI/nti.mailer", zip_safe=True, - packages=find_packages('src'), + packages=find_namespace_packages('src'), package_dir={'': 'src'}, include_package_data=True, - namespace_packages=['nti'], - tests_require=TESTS_REQUIRE, install_requires=[ 'gevent', 'setuptools', @@ -69,15 +66,11 @@ def _read(fname): 'itsdangerous', 'nti.schema', 'repoze.sendmail', - # premailer dropped Python 2 support in version 3.7; - # unfortunately, they don't have the proper ``python_requires`` metadata - # to let installers know this. - 'premailer < 3.7.0; python_version == "2.7"', - 'premailer >= 3.7.0; python_version != "2.7"', + 'premailer >= 3.7.0', # The < 2.0 part is from nti.app.pyramid_zope, a test # dependency. But older released versions on PyPI (< 0.0.3) # do not specify this correctly. - 'pyramid < 2.0', + #'pyramid < 2.0', 'pyramid_mailer', 'six', 'ZODB', @@ -104,5 +97,5 @@ def _read(fname): ], }, entry_points=entry_points, - python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5", + python_requires=">=3.10", ) diff --git a/src/nti/__init__.py b/src/nti/__init__.py index 656dc0f..69e3be5 100644 --- a/src/nti/__init__.py +++ b/src/nti/__init__.py @@ -1 +1 @@ -__import__('pkg_resources').declare_namespace(__name__) # pragma: no cover +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/src/nti/mailer/_default_template_mailer.py b/src/nti/mailer/_default_template_mailer.py index c5ebd9e..0158c16 100644 --- a/src/nti/mailer/_default_template_mailer.py +++ b/src/nti/mailer/_default_template_mailer.py @@ -23,7 +23,6 @@ from pyramid_mailer.message import Message -from six import string_types from zope import component from zope import interface @@ -70,7 +69,7 @@ def _get_renderer_spec_and_package(base_template, extension, package=None, level=3): - if isinstance(package, string_types): + if isinstance(package, str): package = dottedname.resolve(package) # Did they give us a package, either in the name or as an argument? @@ -137,13 +136,14 @@ def _as_recipient_list(recipients): for recipient in recipients: # If we have a principal object, explicitly check if `is_valid_email`. email_validation = IPrincipalEmailValidation(recipient, None) + # pylint:disable=too-many-function-args if email_validation is not None \ and not email_validation.is_valid_email(): continue # Convert any IEmailAddressable into their email, and strip # empty strings recipient = getattr(IEmailAddressable(recipient, recipient), 'email', recipient) - if isinstance(recipient, string_types) and recipient: + if isinstance(recipient, str) and recipient: result.append(recipient) return result @@ -167,8 +167,9 @@ def _make_template_args( if extension == text_template_extension and text_template_extension != '.txt' else 'context' ) - result = {} - result[the_context_name] = context + result = { + the_context_name: context + } result.update(existing_template_args) # Because the "correct" name for the context variable cannot be known @@ -223,7 +224,8 @@ def create_simple_html_text_email(base_template, will be used. (If both *context* or ``request.context`` and a template argument value are given, they should be the same object.) """ - + # XXX: Simplify! + # pylint:disable=too-complex,too-many-locals,too-many-branches,too-many-positional-arguments recipients = _as_recipient_list(recipients) if not recipients: @@ -375,10 +377,13 @@ def queue_simple_html_text_email(*args, **kwargs): Transactionally queues an email for sending. The email has both a plain text and an HTML version. - :keyword text_template_extension: The filename extension for the plain text template. Valid values - are ".txt" for Chameleon templates (this is the default and preferred version) and ".mak" for - Mako templates. Note that if you use Mako, the usual ``context`` argument is renamed to ``nti_context``, - as ``context`` is a reserved word in Mako. + :keyword text_template_extension: + The filename extension for the plain text template. Valid + values are ".txt" for Chameleon templates (this is the default + and preferred version) and ".mak" for Mako templates. Note + that if you use Mako, the usual ``context`` argument is + renamed to ``nti_context``, as ``context`` is a reserved word + in Mako. :return: The :class:`pyramid_mailer.message.Message` we sent. """ diff --git a/src/nti/mailer/_verp.py b/src/nti/mailer/_verp.py index 6a38673..7717c3e 100644 --- a/src/nti/mailer/_verp.py +++ b/src/nti/mailer/_verp.py @@ -6,11 +6,10 @@ .. $Id$ """ -from __future__ import print_function, absolute_import, division - import zlib import struct +from urllib import parse as urllib_parse from itsdangerous.exc import BadSignature @@ -24,7 +23,6 @@ from zope.security.interfaces import IPrincipal -from six.moves import urllib_parse from nti.mailer._compat import parseaddr from nti.mailer._compat import formataddr @@ -166,7 +164,7 @@ def _sign(signer, principal_ids): return _to_bytes(principal_ids) + signer.sep + sig -def realname_from_recipients(fromaddr, recipients, request=None): +def realname_from_recipients(fromaddr, recipients, request=None): # pylint:disable=unused-argument realname, addr = parseaddr(fromaddr) if not realname and not addr: raise ValueError("Invalid fromaddr", fromaddr) @@ -216,7 +214,7 @@ def verp_from_recipients(fromaddr, def principal_ids_from_verp(fromaddr, - request=None, + request=None, # pylint:disable=unused-argument default_key=None): if not fromaddr or '+' not in fromaddr: return () @@ -243,7 +241,6 @@ def principal_ids_from_verp(fromaddr, pids = _to_native_string(pids) except BadSignature: return () - else: - return pids.split(',') + return pids.split(',') interface.moduleProvides(IVERP) diff --git a/src/nti/mailer/interfaces.py b/src/nti/mailer/interfaces.py index 40c29f1..1b72157 100644 --- a/src/nti/mailer/interfaces.py +++ b/src/nti/mailer/interfaces.py @@ -30,13 +30,14 @@ ) # pylint:disable=inherit-non-class,no-self-argument,no-method-argument +# pylint:disable=too-many-positional-arguments from pyramid_mailer.interfaces import IMailer from repoze.sendmail.interfaces import IMailDelivery from nti.schema.field import TextLine -logger = __import__('logging').getLogger(__name__) + class IPrincipalEmailValidation(interface.Interface): @@ -64,7 +65,7 @@ class IEmailAddressable(interface.Interface): example. """ - email = interface.Attribute(u"The email address to send to") + email = interface.Attribute("The email address to send to") @interface.implementer(IEmailAddressable, @@ -262,10 +263,10 @@ class IMailerPolicy(interface.Interface): """ # Deprecated - DEFAULT_EMAIL_SENDER = TextLine(title=u'An optional email sender', - description=u'An email address used to send emails to users' - u'such as account creation, both on behalf of this' - u'object as well as from other places. Optional.', + DEFAULT_EMAIL_SENDER = TextLine(title='An optional email sender', + description='An email address used to send emails to users' + 'such as account creation, both on behalf of this' + 'object as well as from other places. Optional.', required=False, default=None) diff --git a/src/nti/mailer/queue.py b/src/nti/mailer/queue.py index 5bf23ba..160b5c0 100644 --- a/src/nti/mailer/queue.py +++ b/src/nti/mailer/queue.py @@ -117,7 +117,7 @@ def __init__(self, mailer_factory, queue_path, sleep_seconds=120): # pylint: di self.queue_path = queue_path self.mail_dir = Maildir(self.queue_path, create=True) - def _maildir_factory(self, *args, **kwargs): + def _maildir_factory(self, *_args, **_kwargs): return self.mail_dir def _do_process_queue(self): @@ -216,7 +216,7 @@ class MailerWatcher(_AbstractMailerProcess): max_process_frequency_seconds = _MINIMUM_DEBOUNCE_INTERVAL_SECONDS def __init__(self, *args, **kwargs): - super(MailerWatcher, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) hub = gevent.get_hub() to_watch = os.path.join(self.queue_path, 'new') # TODO: Do we need to get abspath() on to_watch? I (JAM) @@ -338,6 +338,8 @@ def run_process(): # pragma: no cover _mailer_factory = SESMailer if arguments.sesregion: + # pylint:disable=redefined-variable-type + # pylint:disable=unnecessary-lambda-assignment _mailer_factory = lambda: SESMailer(arguments.sesregion) app = MailerWatcher(_mailer_factory, arguments.queue_path) diff --git a/src/nti/mailer/tests/test_default_template_mailer.py b/src/nti/mailer/tests/test_default_template_mailer.py index 6cde91b..4248e5b 100644 --- a/src/nti/mailer/tests/test_default_template_mailer.py +++ b/src/nti/mailer/tests/test_default_template_mailer.py @@ -6,8 +6,7 @@ # disable: accessing protected members, too many methods # pylint: disable=W0212,R0904 import unittest - -import fudge +from unittest.mock import patch as Patch from hamcrest import is_ @@ -50,7 +49,7 @@ from nti.mailer.interfaces import EmailAddressablePrincipal from nti.mailer.interfaces import IPrincipalEmailValidation -MSG_DOMAIN = u'nti.mailer.tests' +MSG_DOMAIN = 'nti.mailer.tests' _ = MessageFactory(MSG_DOMAIN) class ITestMailDelivery(IMailer, IMailDelivery): @@ -131,8 +130,8 @@ def testTearDown(cls): @interface.implementer(IPrincipalEmailValidation) class TestEmailAddressablePrincipal(EmailAddressablePrincipal): - def __init__(self, user, is_valid=True, *args, **kwargs): - super(TestEmailAddressablePrincipal, self).__init__(user, *args, **kwargs) + def __init__(self, user, is_valid=True, *args, **kwargs): # pylint:disable=keyword-arg-before-vararg + super().__init__(user, *args, **kwargs) self.is_valid = is_valid def is_valid_email(self): @@ -155,16 +154,16 @@ class TestEmail(unittest.TestCase): layer = PyramidMailerLayer - @fudge.patch('nti.mailer._verp._brand_name') + @Patch('nti.mailer._verp._brand_name', autospec=True) def test_create_mail_message_with_non_ascii_name_and_string_bcc(self, brand_name): - brand_name.is_callable().returns(None) + brand_name.return_value = None class User(object): username = 'the_user' class Profile(object): # Note the umlaut e - realname = u'Suzë Schwartz' + realname = 'Suzë Schwartz' user = User() profile = Profile() @@ -200,9 +199,9 @@ class Profile(object): # assert_that(msg, has_property('bcc', ['foo@bar.com'])) - @fudge.patch('nti.mailer._verp._brand_name') + @Patch('nti.mailer._verp._brand_name', autospec=True) def test_create_email_with_verp(self, brand_name): - brand_name.is_callable().returns(None) + brand_name.return_value = None @interface.implementer(IPrincipal, IEmailAddressable) class User(object): @@ -213,7 +212,7 @@ class User(object): email = 'thomas.stockdale@nextthought.com' class Profile(object): - realname = u'Suzë Schwartz' + realname = 'Suzë Schwartz' user = User() profile = Profile() @@ -268,9 +267,9 @@ class Profile(object): request=request) assert_that(msg, none()) - @fudge.patch('nti.mailer._verp._brand_name') + @Patch('nti.mailer._verp._brand_name', autospec=True) def test_create_email_with_mako(self, brand_name): - brand_name.is_callable().returns(None) + brand_name.return_value = None user = _User('the_user') request = Request() @@ -281,7 +280,7 @@ def test_create_email_with_mako(self, brand_name): user=user) assert_that(msg, is_(not_none())) - @fudge.patch('nti.mailer._verp._brand_name') + @Patch('nti.mailer._verp._brand_name') def test_create_email_no_request_context(self, brand_name): brand_name.is_callable().returns(None) @@ -294,15 +293,15 @@ def test_create_email_no_request_context(self, brand_name): def _create_simple_email(self, request, + *, user=None, profile=None, text_template_extension=".txt", - subject=u'Hi there', + subject='Hi there', context=_NotGiven, reply_to=_NotGiven): - user = user or _User('the_user') - profile = profile or _Profile(u'Mickey Mouse') + profile = profile or _Profile('Mickey Mouse') token_url = 'url_to_verify_email' kwargs = {} @@ -330,18 +329,18 @@ def test_create_email_localizes_subject(self): import warnings request = Request() - subject = _(u'Hi there') + subject = _('Hi there') # If we don't provide a `context` object, by default # the ``translate`` function won't try to negotiate a language; # creating the message works around that by using the `request` as the context. msg = self._create_simple_email(request, subject=subject) - assert_that(msg.subject, is_(u'[[nti.mailer.tests][Hi there]]')) + assert_that(msg.subject, is_('[[nti.mailer.tests][Hi there]]')) # We can be explicit about that with warnings.catch_warnings(): warnings.simplefilter('ignore') msg = self._create_simple_email(request, subject=subject, context=request) - assert_that(msg.subject, is_(u'[[nti.mailer.tests][Hi there]]')) + assert_that(msg.subject, is_('[[nti.mailer.tests][Hi there]]')) # If we *do* provide a context, but there is no # IUserPreferredLanguages available for the context, we @@ -351,12 +350,12 @@ def test_create_email_localizes_subject(self): with warnings.catch_warnings(): warnings.simplefilter('ignore') msg = self._create_simple_email(request, subject=subject) - assert_that(msg.subject, is_(u'[[nti.mailer.tests][Hi there]]')) + assert_that(msg.subject, is_('[[nti.mailer.tests][Hi there]]')) with warnings.catch_warnings(): warnings.simplefilter('ignore') msg = self._create_simple_email(request, subject=subject, context=request) - assert_that(msg.subject, is_(u'[[nti.mailer.tests][Hi there]]')) + assert_that(msg.subject, is_('[[nti.mailer.tests][Hi there]]')) def test_warning_about_mismatch_of_context(self): # If we pass a context argument we get the warning because the @@ -401,31 +400,31 @@ def test_get_renderer_spec_and_package_no_colon_no_package(self): assert_that(template, is_('subdir/no_colon.txt')) assert_that(package, is_(tests)) - @fudge.patch('nti.mailer._default_template_mailer.get_renderer') + @Patch('nti.mailer._default_template_mailer.get_renderer') def test__get_renderer(self, fake_get_renderer): from nti.mailer import tests from .._default_template_mailer import _get_renderer - fake_get_renderer.expects_call().calls(lambda *args, **kwargs: (args, kwargs)) + fake_get_renderer.side_effect = lambda *args, **kwargs: (args, kwargs) args, kwargs = _get_renderer('no_colon', '.txt', level=2) assert_that(args, is_(('templates/no_colon.txt',))) assert_that(kwargs, is_({'package': tests})) - @fudge.patch('nti.mailer._default_template_mailer._get_renderer') + @Patch('nti.mailer._default_template_mailer._get_renderer') def test_do_html_text_templates_exist(self, fake__get_renderer): from .._default_template_mailer import do_html_text_templates_exist class MyException(Exception): pass - def _get_renderer(base_template, extension, package=None, level=3): - if extension in ('.pt', '.good'): + def _get_renderer(base_template, extension, package=None, level=3): # pylint:disable=unused-argument + if extension in {'.pt', '.good'}: return if extension == '.mako': # Unexpected case should propagate raise MyException # Expected case should return false. raise ValueError - fake__get_renderer.expects_call().calls(_get_renderer) + fake__get_renderer.side_effect = _get_renderer # This will raise ValueError on the second one result = do_html_text_templates_exist('base_template') self.assertFalse(result) @@ -464,7 +463,7 @@ def get_template_args(self, request): # unnamed component.provideUtility(A(), IMailerTemplateArgsUtility) # named - component.provideUtility(B(), IMailerTemplateArgsUtility, u'B') + component.provideUtility(B(), IMailerTemplateArgsUtility, 'B') template_args = {"C": 3} template_args_copy = template_args.copy() diff --git a/src/nti/mailer/tests/test_interfaces.py b/src/nti/mailer/tests/test_interfaces.py index bb7bb46..1113a74 100644 --- a/src/nti/mailer/tests/test_interfaces.py +++ b/src/nti/mailer/tests/test_interfaces.py @@ -16,7 +16,7 @@ class AbstractPrincipal(object): id = 'id' email = 'sjohnson@nextthought.com' - def __conform__(self, iface): + def __conform__(self, _iface): # pylint:disable=bad-dunder-name return self diff --git a/src/nti/mailer/tests/test_queue.py b/src/nti/mailer/tests/test_queue.py index b9dbe62..f3b17ca 100644 --- a/src/nti/mailer/tests/test_queue.py +++ b/src/nti/mailer/tests/test_queue.py @@ -11,6 +11,7 @@ from tempfile import mkdtemp import unittest +from unittest.mock import Mock import email @@ -19,12 +20,13 @@ from hamcrest import has_length from hamcrest import none -import fudge + from repoze.sendmail.delivery import QueuedMailDelivery from repoze.sendmail.maildir import Maildir +# pylint:disable-next=import-private-name from repoze.sendmail.tests.test_delivery import _makeMailerStub from nti.mailer.queue import SESMailer @@ -38,7 +40,7 @@ class TestMailer(unittest.TestCase): def setUp(self): - super(TestMailer, self).setUp() + super().setUp() self.message = email.message_from_string(MSG_STRING) def test_region(self): @@ -72,8 +74,8 @@ def test_send(self): def send_raw_email(*_args, **kwargs): send_kwargs.update(kwargs) - mailer.client = fudge.Fake('SESClient') - mailer.client.provides('send_raw_email').calls(send_raw_email) + mailer.client = Mock() #fudge.Fake('SESClient') + mailer.client.send_raw_email.side_effect = send_raw_email mailer.send('from', ('to',), self.message) @@ -164,7 +166,7 @@ class FUT(MailerWatcher): test_one_shot = True def _youve_got_mail(self): - super(FUT, self)._youve_got_mail() + super()._youve_got_mail() # Deterministically close this down now so the event # loop can exit, but only after we've actually # processed something (this prevents hardcoding some @@ -174,10 +176,10 @@ def _youve_got_mail(self): self.close() def _do_process_queue(self): self.test_queue_proc_count += 1 - super(FUT, self)._do_process_queue() + super()._do_process_queue() def _timer_fired(self): self.test_timer_fired_count += 1 - super(FUT, self)._timer_fired() + super()._timer_fired() return FUT diff --git a/src/nti/mailer/tests/test_verp.py b/src/nti/mailer/tests/test_verp.py index 529ec80..771d1cb 100644 --- a/src/nti/mailer/tests/test_verp.py +++ b/src/nti/mailer/tests/test_verp.py @@ -9,12 +9,13 @@ # pylint: disable=W0212,R0904 import contextlib import unittest +from unittest.mock import patch as Patch +from unittest.mock import Mock from hamcrest import is_ from hamcrest import contains_exactly as contains from hamcrest import assert_that -import fudge from zope import component from zope import interface @@ -63,13 +64,13 @@ def test_pids_from_verp_email(self): pids = principal_ids_from_verp(fromaddr) assert_that(pids, is_(())) - @fudge.patch('nti.mailer._verp._get_default_sender', - 'nti.mailer._verp._get_signer_secret') + @Patch('nti.mailer._verp._get_signer_secret', autospec=True) + @Patch('nti.mailer._verp._get_default_sender', autospec=True) def test_verp_from_recipients_in_site_uses_default_sender_realname(self, mock_find, mock_secret): - mock_find.is_callable().returns('Janux ') - mock_secret.is_callable().returns('abc123') + mock_find.return_value = 'Janux ' + mock_secret.return_value = 'abc123' prin = self.email_addr_principal('foo', 'foo@bar.com') @@ -117,9 +118,9 @@ def email_addr_principal(self, principal_id, email_address): prin.id = principal_id return prin - @fudge.patch('nti.mailer._verp._get_default_sender', - 'nti.mailer._verp._get_signer_secret', - 'nti.mailer._verp.get_current_request') + @Patch('nti.mailer._verp.get_current_request', autospec=True) + @Patch('nti.mailer._verp._get_signer_secret', autospec=True) + @Patch('nti.mailer._verp._get_default_sender', autospec=True) def test_verp_from_recipients_no_default_sender_realname(self, mock_find, mock_secret, @@ -128,13 +129,13 @@ def test_verp_from_recipients_no_default_sender_realname(self, If there's no realname for the default sender, use the brand name as a fallback. """ - mock_find.is_callable().returns('janux@ou.edu') - mock_secret.is_callable().returns('abc123') - request = fudge.Fake('Request') + mock_find.return_value = 'janux@ou.edu' + mock_secret.return_value = 'abc123' + # We'll pass it in the first time through, so this shouldn't # be called - get_current_request.is_callable().returns(request).times_called(0) + #get_current_request.is_callable().returns(request).times_called(0) # If we have a IDisplayNameGenerator registered for the current site # we'll use that for the real name @@ -149,7 +150,7 @@ def test_verp_from_recipients_no_default_sender_realname(self, addr = verp_from_recipients('no-reply@nextthought.com', (prin,), default_key='alpha.nextthought.com', - request=request) + request=Mock()) name, email = parseaddr(addr) @@ -157,11 +158,10 @@ def test_verp_from_recipients_no_default_sender_realname(self, assert_that(email, is_('no-reply+foo.UGQXuA@nextthought.com')) # Use current request implicitly - get_current_request.times_called(1) addr = verp_from_recipients('no-reply@nextthought.com', (prin,), default_key='alpha.nextthought.com') - + get_current_request.assert_called_once() name, email = parseaddr(addr) assert_that(name, is_('Brand XYZ')) @@ -172,7 +172,7 @@ def test_verp_from_recipients_no_default_sender_realname(self, addr = verp_from_recipients('no-reply@nextthought.com', (prin,), default_key='alpha.nextthought.com', - request=request) + request=Mock()) name, email = parseaddr(addr)