diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 98d11b9f..51c9c787 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,7 +63,7 @@ jobs: continue-on-error: ${{ matrix.experimental }} strategy: matrix: - python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "pypy-2.7", "pypy-3.7"] + python-version: ["3.6", "3.7", "3.8", "3.9", "pypy-3.7"] twisted-version: ["lowest", "latest"] experimental: [false] @@ -97,13 +97,10 @@ jobs: shell: python run: | table = { - "2.7": "py27", - "3.5": "py35", "3.6": "py36", "3.7": "py37", "3.8": "py38", "3.9": "py39", - "pypy-2.7": "pypy", "pypy-3.7": "pypy3", } factor = table["${{ matrix.python-version }}"] diff --git a/changelog.d/318.removal.rst b/changelog.d/318.removal.rst new file mode 100644 index 00000000..b7229ad9 --- /dev/null +++ b/changelog.d/318.removal.rst @@ -0,0 +1 @@ +Support for Python 2.7 and 3.5 has been dropped. treq no longer depends on ``six`` or ``mock``. diff --git a/docs/testing.rst b/docs/testing.rst index 01ddb0f2..2f292b51 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -33,13 +33,15 @@ Download: :download:`testing_seq.py `. Loosely matching the request ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you don't care about certain parts of the request, you can pass :data:`mock.ANY`, which compares equal to anything. +If you don't care about certain parts of the request, you can pass :data:`unittest.mock.ANY`, which compares equal to anything. This sequence matches a single GET request with any parameters or headers: .. code-block:: python + from unittest.mock import ANY + RequestSequence([ - ((b'get', mock.ANY, mock.ANY, b''), (200, {}, b'ok')) + ((b'get', ANY, ANY, b''), (200, {}, b'ok')) ]) diff --git a/setup.py b/setup.py index 528bed28..3c3ca287 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,6 @@ "Operating System :: OS Independent", "Framework :: Twisted", "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", @@ -28,18 +26,16 @@ package_dir={"": "src"}, setup_requires=["incremental"], use_incremental=True, - python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', + python_requires='>=3.6', install_requires=[ "incremental", "requests >= 2.1.0", "hyperlink >= 21.0.0", - "six >= 1.13.0", "Twisted[tls] >= 18.7.0", "attrs", ], extras_require={ "dev": [ - "mock", "pep8", "pyflakes", "sphinx", diff --git a/src/treq/_agentspy.py b/src/treq/_agentspy.py index 52fc3f5b..42475bab 100644 --- a/src/treq/_agentspy.py +++ b/src/treq/_agentspy.py @@ -10,7 +10,7 @@ @attr.s(frozen=True, order=False, slots=True) -class RequestRecord(object): +class RequestRecord: """ The details of a call to :meth:`_AgentSpy.request` @@ -30,7 +30,7 @@ class RequestRecord(object): @implementer(IAgent) @attr.s -class _AgentSpy(object): +class _AgentSpy: """ An agent that records HTTP requests diff --git a/src/treq/auth.py b/src/treq/auth.py index fad3df6c..6ed986dd 100644 --- a/src/treq/auth.py +++ b/src/treq/auth.py @@ -20,7 +20,7 @@ def __init__(self, config): @implementer(IAgent) -class _RequestHeaderSetterAgent(object): +class _RequestHeaderSetterAgent: """ Wrap an agent to set request headers diff --git a/src/treq/client.py b/src/treq/client.py index 98b56892..7924d110 100644 --- a/src/treq/client.py +++ b/src/treq/client.py @@ -1,15 +1,10 @@ -from __future__ import absolute_import, division, print_function - +import io import mimetypes import uuid import warnings - -import io - -import six -from six.moves.collections_abc import Mapping -from six.moves.http_cookiejar import CookieJar -from six.moves.urllib.parse import quote_plus, urlencode as _urlencode +from collections.abc import Mapping +from http.cookiejar import CookieJar +from urllib.parse import quote_plus, urlencode as _urlencode from twisted.internet.interfaces import IProtocol from twisted.internet.defer import Deferred @@ -42,7 +37,10 @@ def urlencode(query, doseq): - return six.ensure_binary(_urlencode(query, doseq), encoding='ascii') + s = _urlencode(query, doseq) + if not isinstance(s, bytes): + s = s.encode("ascii") + return s class _BodyBufferingProtocol(proxyForInterface(IProtocol)): @@ -96,7 +94,7 @@ def deliverBody(self, protocol): self._waiters.append(protocol) -class HTTPClient(object): +class HTTPClient: def __init__(self, agent, cookiejar=None, data_to_body_producer=IBodyProducer): self._agent = agent @@ -156,7 +154,7 @@ def request(self, method, url, **kwargs): parsed_url = url.encoded_url elif isinstance(url, EncodedURL): parsed_url = url - elif isinstance(url, six.text_type): + elif isinstance(url, str): # We use hyperlink in lazy mode so that users can pass arbitrary # bytes in the path and querystring. parsed_url = EncodedURL.from_text(url) @@ -250,7 +248,7 @@ def _request_headers(self, headers, stacklevel): if isinstance(headers, dict): h = Headers({}) for k, v in headers.items(): - if isinstance(v, (bytes, six.text_type)): + if isinstance(v, (bytes, str)): h.addRawHeader(k, v) elif isinstance(v, list): h.setRawHeaders(k, v) @@ -432,7 +430,7 @@ def _query_quote(v): a querystring (with space as ``+``). """ if not isinstance(v, (str, bytes)): - v = six.text_type(v) + v = str(v) if not isinstance(v, bytes): v = v.encode("utf-8") q = quote_plus(v) @@ -496,10 +494,5 @@ def _guess_content_type(filename): registerAdapter(_from_bytes, bytes, IBodyProducer) registerAdapter(_from_file, io.BytesIO, IBodyProducer) -if six.PY2: - registerAdapter(_from_file, six.StringIO, IBodyProducer) - # Suppress lint failure on Python 3. - registerAdapter(_from_file, file, IBodyProducer) # noqa: F821 -else: - # file()/open() equiv on Py3 - registerAdapter(_from_file, io.BufferedReader, IBodyProducer) +# file()/open() equiv +registerAdapter(_from_file, io.BufferedReader, IBodyProducer) diff --git a/src/treq/content.py b/src/treq/content.py index c6ac1406..36219f54 100644 --- a/src/treq/content.py +++ b/src/treq/content.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, division, print_function - import cgi import json diff --git a/src/treq/multipart.py b/src/treq/multipart.py index e61c1ac0..bb5116e5 100644 --- a/src/treq/multipart.py +++ b/src/treq/multipart.py @@ -1,14 +1,10 @@ # Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. -from __future__ import absolute_import, division, print_function - from uuid import uuid4 from io import BytesIO from contextlib import closing -from six import integer_types, text_type - from twisted.internet import defer, task from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer @@ -18,7 +14,7 @@ @implementer(IBodyProducer) -class MultiPartProducer(object): +class MultiPartProducer: """ :class:`MultiPartProducer` takes parameters for a HTTP request and produces bytes in multipart/form-data format defined in :rfc:`2388` and @@ -60,7 +56,7 @@ def __init__(self, fields, boundary=None, cooperator=task): self.boundary = boundary or uuid4().hex - if isinstance(self.boundary, text_type): + if isinstance(self.boundary, str): self.boundary = self.boundary.encode('ascii') self.length = self._calculateLength() @@ -169,7 +165,7 @@ def _writeLoop(self, consumer): consumer.write(CRLF + self._getBoundary(final=True) + CRLF) def _writeField(self, name, value, consumer): - if isinstance(value, text_type): + if isinstance(value, str): self._writeString(name, value, consumer) elif isinstance(value, tuple): filename, content_type, producer = value @@ -218,8 +214,8 @@ def _escape(value): a newline in the file name parameter makes form-data request unreadable for majority of parsers. """ - if not isinstance(value, (bytes, text_type)): - value = text_type(value) + if not isinstance(value, (bytes, str)): + value = str(value) if isinstance(value, bytes): value = value.decode('utf-8') return value.replace(u"\r", u"").replace(u"\n", u"").replace(u'"', u'\\"') @@ -227,19 +223,19 @@ def _escape(value): def _enforce_unicode(value): """ - This function enforces the stings passed to be unicode, so we won't + This function enforces the strings passed to be unicode, so we won't need to guess what's the encoding of the binary strings passed in. If someone needs to pass the binary string, use BytesIO and wrap it with `FileBodyProducer`. """ - if isinstance(value, text_type): + if isinstance(value, str): return value elif isinstance(value, bytes): - # we got a byte string, and we have no ide what's the encoding of it + # we got a byte string, and we have no idea what's the encoding of it # we can only assume that it's something cool try: - return text_type(value, "utf-8") + return value.decode("utf-8") except UnicodeDecodeError: raise ValueError( "Supplied raw bytes that are not ascii/utf-8." @@ -267,7 +263,7 @@ def _converted(fields): filename = _enforce_unicode(filename) if filename else None yield name, (filename, content_type, producer) - elif isinstance(value, (bytes, text_type)): + elif isinstance(value, (bytes, str)): yield name, _enforce_unicode(value) else: @@ -276,7 +272,7 @@ def _converted(fields): "or tuple (filename, content type, IBodyProducer)") -class _LengthConsumer(object): +class _LengthConsumer: """ `_LengthConsumer` is used to calculate the length of the multi-part request. The easiest way to do that is to consume all the fields, @@ -300,13 +296,13 @@ def write(self, value): if value is UNKNOWN_LENGTH: self.length = value - elif isinstance(value, integer_types): + elif isinstance(value, int): self.length += value else: self.length += len(value) -class _Header(object): +class _Header: """ `_Header` This class is a tiny wrapper that produces request headers. We can't use standard python header @@ -347,7 +343,7 @@ def _sorted_by_type(fields): """ def key(p): key, val = p - if isinstance(val, (bytes, text_type)): + if isinstance(val, (bytes, str)): return (0, key) else: return (1, key) diff --git a/src/treq/response.py b/src/treq/response.py index 3689c8a2..d13c3edb 100644 --- a/src/treq/response.py +++ b/src/treq/response.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, division, print_function - from twisted.python.components import proxyForInterface from twisted.web.iweb import IResponse, UNKNOWN_LENGTH from twisted.python import reflect diff --git a/src/treq/test/local_httpbin/child.py b/src/treq/test/local_httpbin/child.py index dcd1f797..4a6914e9 100644 --- a/src/treq/test/local_httpbin/child.py +++ b/src/treq/test/local_httpbin/child.py @@ -10,9 +10,7 @@ import httpbin -import six - -from twisted.internet.defer import Deferred, inlineCallbacks, returnValue +from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.endpoints import TCP4ServerEndpoint, SSL4ServerEndpoint from twisted.internet.task import react from twisted.internet.ssl import (Certificate, @@ -158,11 +156,9 @@ def _serve_tls(reactor, host, port, site): :return: A :py:class:`Deferred` that fires with a :py:class:`_HTTPBinDescription` """ - cert_host = host.decode('ascii') if six.PY2 else host - ( ca_cert, private_key, certificate, - ) = _certificates_for_authority_and_server(cert_host) + ) = _certificates_for_authority_and_server(host) context_factory = CertificateOptions(privateKey=private_key, certificate=certificate) @@ -178,7 +174,7 @@ def _serve_tls(reactor, host, port, site): port=port.getHost().port, cacert=ca_cert.dumpPEM().decode('ascii')) - returnValue(description) + return description @inlineCallbacks @@ -202,7 +198,7 @@ def _serve_tcp(reactor, host, port, site): description = _HTTPBinDescription(host=host, port=port.getHost().port) - returnValue(description) + return description def _output_process_description(description, stdout=sys.stdout): @@ -214,15 +210,8 @@ def _output_process_description(description, stdout=sys.stdout): :param stdout: (optional) Standard out. """ - if six.PY2: - write = stdout.write - flush = stdout.flush - else: - write = stdout.buffer.write - flush = stdout.buffer.flush - - write(description.to_json_bytes() + b'\n') - flush() + stdout.buffer.write(description.to_json_bytes() + b'\n') + stdout.buffer.flush() def _forever_httpbin(reactor, argv, diff --git a/src/treq/test/local_httpbin/parent.py b/src/treq/test/local_httpbin/parent.py index bb940a2f..78dedaef 100644 --- a/src/treq/test/local_httpbin/parent.py +++ b/src/treq/test/local_httpbin/parent.py @@ -59,7 +59,7 @@ def connectionLost(self, reason): @attr.s -class _HTTPBinProcess(object): +class _HTTPBinProcess: """ Manage an ``httpbin`` server process. diff --git a/src/treq/test/local_httpbin/shared.py b/src/treq/test/local_httpbin/shared.py index f0572256..78016c45 100644 --- a/src/treq/test/local_httpbin/shared.py +++ b/src/treq/test/local_httpbin/shared.py @@ -6,7 +6,7 @@ @attr.s -class _HTTPBinDescription(object): +class _HTTPBinDescription: """ Describe an ``httpbin`` process. diff --git a/src/treq/test/local_httpbin/test/test_child.py b/src/treq/test/local_httpbin/test/test_child.py index 7e767e15..2a08d4e3 100644 --- a/src/treq/test/local_httpbin/test/test_child.py +++ b/src/treq/test/local_httpbin/test/test_child.py @@ -25,8 +25,6 @@ from service_identity.cryptography import verify_certificate_hostname -import six - from .. import child, shared @@ -263,14 +261,13 @@ def flush(self): self._state.flush_count += 1 -if not six.PY2: - @attr.s - class BufferedStandardOut(object): - """ - A standard out that whose ``buffer`` is a - :py:class:`FlushableBytesIO` instance. - """ - buffer = attr.ib() +@attr.s +class BufferedStandardOut(object): + """ + A standard out that whose ``buffer`` is a + :py:class:`FlushableBytesIO` instance. + """ + buffer = attr.ib() class OutputProcessDescriptionTests(SynchronousTestCase): @@ -280,9 +277,7 @@ class OutputProcessDescriptionTests(SynchronousTestCase): def setUp(self): self.stdout_state = FlushableBytesIOState() - self.stdout = FlushableBytesIO(self.stdout_state) - if not six.PY2: - self.stdout = BufferedStandardOut(self.stdout) + self.stdout = BufferedStandardOut(FlushableBytesIO(self.stdout_state)) def test_description_written(self): """ diff --git a/src/treq/test/test_api.py b/src/treq/test/test_api.py index 782a35f4..f7f9e093 100644 --- a/src/treq/test/test_api.py +++ b/src/treq/test/test_api.py @@ -15,7 +15,7 @@ from twisted.test.proto_helpers import MemoryReactorClock -class SyntacticAbominationHTTPConnectionPool(object): +class SyntacticAbominationHTTPConnectionPool: """ A HTTP connection pool that always fails to return a connection, but counts the number of requests made. @@ -80,7 +80,7 @@ def test_custom_agent(self): """ @implementer(IAgent) - class CounterAgent(object): + class CounterAgent: requests = 0 def request(self, method, uri, headers=None, bodyProducer=None): diff --git a/src/treq/test/test_client.py b/src/treq/test/test_client.py index e4481c62..ee9f3fdb 100644 --- a/src/treq/test/test_client.py +++ b/src/treq/test/test_client.py @@ -1,8 +1,7 @@ -# -*- encoding: utf-8 -*- from collections import OrderedDict from io import BytesIO -import mock +from unittest import mock from hyperlink import DecodedURL, EncodedURL from twisted.internet.defer import Deferred, succeed, CancelledError diff --git a/src/treq/test/test_content.py b/src/treq/test/test_content.py index 60cd998b..9acb9d76 100644 --- a/src/treq/test/test_content.py +++ b/src/treq/test/test_content.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -import mock +from unittest import mock from twisted.python.failure import Failure diff --git a/src/treq/test/test_multipart.py b/src/treq/test/test_multipart.py index a66c059c..fd170e3d 100644 --- a/src/treq/test/test_multipart.py +++ b/src/treq/test/test_multipart.py @@ -1,4 +1,3 @@ -# coding: utf-8 # Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. @@ -10,17 +9,12 @@ from twisted.trial import unittest from zope.interface.verify import verifyObject -from six import PY3, text_type - from twisted.internet import task from twisted.web.client import FileBodyProducer from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer from treq.multipart import MultiPartProducer, _LengthConsumer -if PY3: - long = int - class MultiPartProducerTestCase(unittest.TestCase): """ @@ -65,7 +59,7 @@ def getOutput(self, producer, with_producer=False): def newLines(self, value): - if isinstance(value, text_type): + if isinstance(value, str): return value.replace(u"\n", u"\r\n") else: return value.replace(b"\n", b"\r\n") @@ -84,11 +78,11 @@ def test_unknownLength(self): passed as a parameter without either a C{seek} or C{tell} method, its C{length} attribute is set to C{UNKNOWN_LENGTH}. """ - class HasSeek(object): + class HasSeek: def seek(self, offset, whence): pass - class HasTell(object): + class HasTell: def tell(self): pass @@ -199,7 +193,7 @@ def test_failedReadWhileProducing(self): L{MultiPartProducer.startProducing} fires with a L{Failure} wrapping that exception. """ - class BrokenFile(object): + class BrokenFile: def read(self, count): raise IOError("Simulated bad thing") @@ -622,15 +616,15 @@ class LengthConsumerTestCase(unittest.TestCase): def test_scalarsUpdateCounter(self): """ - When a long or an int are written, _LengthConsumer updates its internal + When an int is written, _LengthConsumer updates its internal counter. """ consumer = _LengthConsumer() self.assertEqual(consumer.length, 0) - consumer.write(long(1)) + consumer.write(1) self.assertEqual(consumer.length, 1) consumer.write(2147483647) - self.assertEqual(consumer.length, long(2147483648)) + self.assertEqual(consumer.length, 2147483648) def test_stringUpdatesCounter(self): """ diff --git a/src/treq/test/test_response.py b/src/treq/test/test_response.py index e63bc587..77941e87 100644 --- a/src/treq/test/test_response.py +++ b/src/treq/test/test_response.py @@ -10,7 +10,7 @@ from treq.response import _Response -class FakeResponse(object): +class FakeResponse: def __init__(self, code, headers, body=()): self.code = code self.headers = headers diff --git a/src/treq/test/test_testing.py b/src/treq/test/test_testing.py index 01729b87..eb804158 100644 --- a/src/treq/test/test_testing.py +++ b/src/treq/test/test_testing.py @@ -4,9 +4,7 @@ from functools import partial from inspect import getmembers, isfunction -from mock import ANY - -from six import text_type, binary_type, PY3 +from unittest.mock import ANY from twisted.trial.unittest import TestCase from twisted.web.client import ResponseFailed @@ -163,9 +161,9 @@ def test_passing_in_strange_data_is_rejected(self): self.successResultOf(stub.request('method', 'http://url', data=[])) self.successResultOf(stub.request('method', 'http://url', data=())) self.successResultOf( - stub.request('method', 'http://url', data=binary_type(b""))) + stub.request('method', 'http://url', data=b"")) self.successResultOf( - stub.request('method', 'http://url', data=text_type(""))) + stub.request('method', 'http://url', data="")) def test_handles_failing_asynchronous_requests(self): """ @@ -306,11 +304,10 @@ def test_repr(self): """ :obj:`HasHeaders` returns a nice string repr. """ - if PY3: - reprOutput = "HasHeaders({b'a': [b'b']})" - else: - reprOutput = "HasHeaders({'a': ['b']})" - self.assertEqual(reprOutput, repr(HasHeaders({b'A': [b'b']}))) + self.assertEqual( + "HasHeaders({b'a': [b'b']})", + repr(HasHeaders({b"A": [b"b"]})), + ) class StringStubbingTests(TestCase): diff --git a/src/treq/test/util.py b/src/treq/test/util.py index 16f82c27..c03ca002 100644 --- a/src/treq/test/util.py +++ b/src/treq/test/util.py @@ -1,7 +1,7 @@ import os import platform -import mock +from unittest import mock from twisted.internet import reactor from twisted.internet.task import Clock diff --git a/src/treq/testing.py b/src/treq/testing.py index c05cc2e5..df633beb 100644 --- a/src/treq/testing.py +++ b/src/treq/testing.py @@ -1,12 +1,7 @@ -# -*- coding: utf-8 -*- """ In-memory version of treq for testing. """ -from __future__ import absolute_import, division, print_function - -from six import text_type, PY3 - from contextlib import contextmanager from functools import wraps @@ -42,7 +37,7 @@ @implementer(IAgentEndpointFactory) @attr.s -class _EndpointFactory(object): +class _EndpointFactory: """ An endpoint factory used by :class:`RequestTraversalAgent`. @@ -77,7 +72,7 @@ def endpointForURI(self, uri): @implementer(IAgent) -class RequestTraversalAgent(object): +class RequestTraversalAgent: """ :obj:`~twisted.web.iweb.IAgent` implementation that issues an in-memory request rather than going out to a real network socket. @@ -120,10 +115,7 @@ def check_already_called(r): # the tcpClients list. Alternately, it will try to establish an HTTPS # connection with the reactor's connectSSL method, and MemoryReactor # will place it into the sslClients list. We'll extract that. - if PY3: - scheme = URLPath.fromBytes(uri).scheme - else: - scheme = URLPath.fromString(uri).scheme + scheme = URLPath.fromBytes(uri).scheme host, port, factory, timeout, bindAddress = ( self._memoryReactor.tcpClients[-1]) @@ -176,7 +168,7 @@ def flush(self): @implementer(IBodyProducer) -class _SynchronousProducer(object): +class _SynchronousProducer: """ A partial implementation of an :obj:`IBodyProducer` which produces its entire payload immediately. There is no way to access to an instance of @@ -197,8 +189,8 @@ def __init__(self, body): self.body = body msg = ("StubTreq currently only supports url-encodable types, bytes, " "or unicode as data.") - assert isinstance(body, (bytes, text_type)), msg - if isinstance(body, text_type): + assert isinstance(body, (bytes, str)), msg + if isinstance(body, str): self.body = body.encode('utf-8') self.length = len(body) @@ -223,7 +215,7 @@ def wrapper(*args, **kwargs): return wrapper -class StubTreq(object): +class StubTreq: """ A fake version of the treq module that can be used for testing that provides all the function calls exposed in :obj:`treq.__all__`. @@ -328,7 +320,7 @@ def _maybeEncode(someStr): """ Encode `someStr` to ASCII if required. """ - if isinstance(someStr, text_type): + if isinstance(someStr, str): return someStr.encode('ascii') return someStr @@ -339,7 +331,7 @@ def _maybeEncodeHeaders(headers): for k, vs in headers.items()} -class HasHeaders(object): +class HasHeaders: """ Since Twisted adds headers to a request, such as the host and the content length, it's necessary to test whether request headers CONTAIN the expected @@ -369,7 +361,7 @@ def __ne__(self, other_headers): return not self.__eq__(other_headers) -class RequestSequence(object): +class RequestSequence: """ For an example usage, see :meth:`RequestSequence.consume`. diff --git a/tox.ini b/tox.ini index 0f87167f..4abd3119 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,14 @@ [tox] envlist = - {pypy,py27,py35,py36,py37}-twisted_lowest, - {pypy,pypy3,py27,py35,py36,py37,py38,py39}-twisted_latest, - {pypy3,py35,py36,py37,py38,py39}-twisted_trunk, + {py36,py37}-twisted_lowest, + {pypy3,py36,py37,py38,py39}-twisted_latest, + {pypy3,py36,py37,py38,py39}-twisted_trunk, towncrier, twine, check-manifest, flake8, docs [testenv] extras = dev deps = coverage - mock twisted_lowest: Twisted==18.7.0 twisted_latest: Twisted