diff --git a/.coveragerc b/.coveragerc index 0cadb4f..f6c1f88 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,3 +7,4 @@ omit= precision = 1 exclude_lines = pragma: no cover + if TYPE_CHECKING: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..80f66fc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: + push: + branches-ignore: + - "dependabot/**" + pull_request: + +jobs: + Lint: + name: 'Lint' + timeout-minutes: 10 + runs-on: 'ubuntu-latest' + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: '3.9' + - name: Run lint + run: ./lint.sh diff --git a/lint-requirements.in b/lint-requirements.in new file mode 100644 index 0000000..0a07786 --- /dev/null +++ b/lint-requirements.in @@ -0,0 +1,6 @@ +# Typing +mypy[python2]==0.910 +# TODO: Switch to cryptography>=35.0.0 once it's released. +types-cryptography>=3.3.3 +types-pyopenssl>=20.0.4 +py>=1.9.0 diff --git a/lint-requirements.txt b/lint-requirements.txt new file mode 100644 index 0000000..34a484f --- /dev/null +++ b/lint-requirements.txt @@ -0,0 +1,28 @@ +# +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: +# +# pip-compile lint-requirements.in +# +mypy[python2]==0.910 + # via -r lint-requirements.in +mypy-extensions==0.4.3 + # via mypy +py==1.10.0 + # via -r lint-requirements.in +toml==0.10.2 + # via mypy +typed-ast==1.4.3 + # via mypy +types-cryptography==3.3.3 + # via + # -r lint-requirements.in + # types-pyopenssl +types-enum34==0.1.8 + # via types-cryptography +types-ipaddress==0.1.5 + # via types-cryptography +types-pyopenssl==20.0.4 + # via -r lint-requirements.in +typing-extensions==3.10.0.0 + # via mypy diff --git a/lint.sh b/lint.sh new file mode 100755 index 0000000..dd36cf5 --- /dev/null +++ b/lint.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -exu -o pipefail + +python -c "import sys, struct, ssl; print('#' * 70); print('python:', sys.version); print('version_info:', sys.version_info); print('bits:', struct.calcsize('P') * 8); print('openssl:', ssl.OPENSSL_VERSION, ssl.OPENSSL_VERSION_INFO); print('#' * 70)" + +python -m pip install -U pip setuptools wheel +python -m pip --version + +# Dependencies + +python -m pip install -Ur lint-requirements.txt + +# Linting + +mypy trustme tests +mypy -2 trustme tests diff --git a/newsfragments/339.feature.rst b/newsfragments/339.feature.rst new file mode 100644 index 0000000..3a23b83 --- /dev/null +++ b/newsfragments/339.feature.rst @@ -0,0 +1 @@ +The package is now type annotated. If you use mypy on code which uses ``trustme``, you should be able to remove any exclusions. diff --git a/pyproject.toml b/pyproject.toml index 0a1c793..c539fb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,3 +17,26 @@ directory = "newsfragments" underlines = ["-", "~", "^"] # Requires https://github.com/hawkowl/towncrier/pull/66 issue_format = "`#{issue} `__" + +[tool.mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +no_implicit_reexport = true +show_error_codes = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +# Some ignores are only for python2/python3. +# warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = "pytest" +ignore_missing_imports = true diff --git a/setup.py b/setup.py index e5922e0..3be6929 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,9 @@ author_email="njs@pobox.com", license="MIT -or- Apache License 2.0", packages=find_packages(), + package_data={ + 'trustme': ['py.typed'], + }, url="https://github.com/python-trio/trustme", install_requires=[ "cryptography", diff --git a/tests/test_cli.py b/tests/test_cli.py index 572e065..3881dab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,12 +3,18 @@ import subprocess import sys +import py import pytest from trustme._cli import main +TYPE_CHECKING = False +if TYPE_CHECKING: # pragma: no cover + from typing import Any + def test_trustme_cli(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): main(argv=[]) @@ -18,6 +24,7 @@ def test_trustme_cli(tmpdir): def test_trustme_cli_e2e(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): rv = subprocess.call([sys.executable, "-m", "trustme"]) assert rv == 0 @@ -28,6 +35,7 @@ def test_trustme_cli_e2e(tmpdir): def test_trustme_cli_directory(tmpdir): + # type: (py.path.local) -> None subdir = tmpdir.mkdir("sub") main(argv=["-d", str(subdir)]) @@ -37,12 +45,14 @@ def test_trustme_cli_directory(tmpdir): def test_trustme_cli_directory_does_not_exist(tmpdir): + # type: (py.path.local) -> None notdir = tmpdir.join("notdir") with pytest.raises(ValueError, match="is not a directory"): main(argv=["-d", str(notdir)]) def test_trustme_cli_identities(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): main(argv=["-i", "example.org", "www.example.org"]) @@ -52,11 +62,13 @@ def test_trustme_cli_identities(tmpdir): def test_trustme_cli_identities_empty(tmpdir): + # type: (py.path.local) -> None with pytest.raises(ValueError, match="at least one identity"): main(argv=["-i"]) def test_trustme_cli_common_name(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): main(argv=["--common-name", "localhost"]) @@ -66,6 +78,7 @@ def test_trustme_cli_common_name(tmpdir): def test_trustme_cli_expires_on(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): main(argv=["--expires-on", "2035-03-01"]) @@ -75,6 +88,7 @@ def test_trustme_cli_expires_on(tmpdir): def test_trustme_cli_invalid_expires_on(tmpdir): + # type: (py.path.local) -> None with tmpdir.as_cwd(): with pytest.raises(ValueError, match="does not match format"): main(argv=["--expires-on", "foobar"]) @@ -85,6 +99,7 @@ def test_trustme_cli_invalid_expires_on(tmpdir): def test_trustme_cli_quiet(capsys, tmpdir): + # type: (Any, py.path.local) -> None with tmpdir.as_cwd(): main(argv=["-q"]) diff --git a/tests/test_trustme.py b/tests/test_trustme.py index 23d9229..f74b409 100644 --- a/tests/test_trustme.py +++ b/tests/test_trustme.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import py import pytest import sys @@ -7,7 +8,7 @@ import socket import threading import datetime -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor # type: ignore[import] from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network @@ -16,19 +17,27 @@ from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, load_pem_private_key) -import OpenSSL -import service_identity.pyopenssl +import OpenSSL.SSL +import service_identity.pyopenssl # type: ignore[import] import trustme -from trustme import CA +from trustme import CA, LeafCert + +TYPE_CHECKING = False +if TYPE_CHECKING: # pragma: no cover + from typing import Callable, Optional, Text, Union + + SslSocket = Union[ssl.SSLSocket, OpenSSL.SSL.Connection] def _path_length(ca_cert): + # type: (x509.Certificate) -> Optional[int] bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) return bc.value.path_length def assert_is_ca(ca_cert): + # type: (x509.Certificate) -> None bc = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints) assert bc.value.ca is True assert bc.critical is True @@ -43,6 +52,7 @@ def assert_is_ca(ca_cert): def assert_is_leaf(leaf_cert): + # type: (x509.Certificate) -> None bc = leaf_cert.extensions.get_extension_for_class(x509.BasicConstraints) assert bc.value.ca is False assert bc.critical is True @@ -64,6 +74,7 @@ def assert_is_leaf(leaf_cert): def test_basics(): + # type: () -> None ca = CA() today = datetime.datetime.today() @@ -112,6 +123,7 @@ def test_basics(): def test_ca_custom_names(): + # type: () -> None ca = CA( organization_name=u'python-trio', organization_unit_name=u'trustme', @@ -132,6 +144,7 @@ def test_ca_custom_names(): def test_issue_cert_custom_names(): + # type: () -> None ca = CA() leaf_cert = ca.issue_cert( u'example.org', @@ -154,6 +167,7 @@ def test_issue_cert_custom_names(): def test_issue_cert_custom_not_after(): + # type: () -> None now = datetime.datetime.now() expires = datetime.datetime(2025, 12, 1, 8, 10, 10) ca = CA() @@ -175,6 +189,7 @@ def test_issue_cert_custom_not_after(): def test_intermediate(): + # type: () -> None ca = CA() ca_cert = x509.load_pem_x509_certificate( ca.cert_pem.bytes(), default_backend()) @@ -198,6 +213,7 @@ def test_intermediate(): def test_path_length(): + # type: () -> None ca = CA() ca_cert = x509.load_pem_x509_certificate( ca.cert_pem.bytes(), default_backend()) @@ -216,17 +232,19 @@ def test_path_length(): def test_unrecognized_context_type(): + # type: () -> None ca = CA() server = ca.issue_cert(u"test-1.example.org") with pytest.raises(TypeError): - ca.configure_trust(None) + ca.configure_trust(None) # type: ignore[arg-type] with pytest.raises(TypeError): - server.configure_cert(None) + server.configure_cert(None) # type: ignore[arg-type] def test_blob(tmpdir): + # type: (py.path.local) -> None test_data = b"xyzzy" b = trustme.Blob(test_data) @@ -262,6 +280,7 @@ def test_blob(tmpdir): assert f.read() == test_data def test_ca_from_pem(tmpdir): + # type: (py.path.local) -> None ca1 = trustme.CA() ca2 = trustme.CA.from_pem(ca1.cert_pem.bytes(), ca1.private_key_pem.bytes()) assert ca1._certificate == ca2._certificate @@ -269,8 +288,10 @@ def test_ca_from_pem(tmpdir): def check_connection_end_to_end(wrap_client, wrap_server): + # type: (Callable[[CA, socket.socket, Text], SslSocket], Callable[[LeafCert, socket.socket], SslSocket]) -> None # Client side def fake_ssl_client(ca, raw_client_sock, hostname): + # type: (CA, socket.socket, Text) -> None try: wrapped_client_sock = wrap_client(ca, raw_client_sock, hostname) # Send and receive some data to prove the connection is good @@ -285,6 +306,7 @@ def fake_ssl_client(ca, raw_client_sock, hostname): # Server side def fake_ssl_server(server_cert, raw_server_sock): + # type: (LeafCert, socket.socket) -> None try: wrapped_server_sock = wrap_server(server_cert, raw_server_sock) # Prove that we're connected @@ -298,6 +320,7 @@ def fake_ssl_server(server_cert, raw_server_sock): raw_server_sock.close() def doit(ca, hostname, server_cert): + # type: (CA, Text, LeafCert) -> None # socketpair and ssl don't work together on py2, because... reasons. # So we need to do this the hard way. listener = socket.socket() @@ -338,18 +361,23 @@ def doit(ca, hostname, server_cert): def test_stdlib_end_to_end(): + # type: () -> None def wrap_client(ca, raw_client_sock, hostname): + # type: (CA, socket.socket, Text) -> ssl.SSLSocket ctx = ssl.create_default_context() ca.configure_trust(ctx) + # Type ignore for Python 2: wants str, got unicode, but I guess unicode also works. wrapped_client_sock = ctx.wrap_socket( - raw_client_sock, server_hostname=hostname) + raw_client_sock, server_hostname=hostname) # type: ignore[arg-type] print("Client got server cert:", wrapped_client_sock.getpeercert()) peercert = wrapped_client_sock.getpeercert() + assert peercert is not None san = peercert["subjectAltName"] assert san == (("DNS", "my-test-host.example.org"),) return wrapped_client_sock def wrap_server(server_cert, raw_server_sock): + # type: (LeafCert, socket.socket) -> ssl.SSLSocket ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) server_cert.configure_cert(ctx) wrapped_server_sock = ctx.wrap_socket( @@ -361,12 +389,14 @@ def wrap_server(server_cert, raw_server_sock): def test_pyopenssl_end_to_end(): + # type: () -> None def wrap_client(ca, raw_client_sock, hostname): + # type: (CA, socket.socket, Text) -> OpenSSL.SSL.Connection # Cribbed from example at # https://service-identity.readthedocs.io/en/stable/api.html#service_identity.pyopenssl.verify_hostname ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) ctx.set_verify(OpenSSL.SSL.VERIFY_PEER, - lambda conn, cert, errno, depth, ok: ok) + lambda conn, cert, errno, depth, ok: bool(ok)) ca.configure_trust(ctx) conn = OpenSSL.SSL.Connection(ctx, raw_client_sock) conn.set_connect_state() @@ -375,6 +405,7 @@ def wrap_client(ca, raw_client_sock, hostname): return conn def wrap_server(server_cert, raw_server_sock): + # type: (LeafCert, socket.socket) -> OpenSSL.SSL.Connection ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) server_cert.configure_cert(ctx) @@ -387,11 +418,12 @@ def wrap_server(server_cert, raw_server_sock): def test_identity_variants(): + # type: () -> None ca = CA() for bad in [b"example.org", bytearray(b"example.org"), 123]: with pytest.raises(TypeError): - ca.issue_cert(bad) + ca.issue_cert(bad) # type: ignore[arg-type] cases = { # Traditional ascii hostname @@ -443,27 +475,29 @@ def test_identity_variants(): x509.SubjectAlternativeName ) assert_is_leaf(cert) - got = san.value[0] + got = list(san.value)[0] assert got == expected def test_backcompat(): + # type: () -> None ca = CA() # We can still use the old name ca.issue_server_cert(u"example.com") def test_CN(): + # type: () -> None ca = CA() # Since we have to emulate kwonly args here, I guess we should test the # emulation logic with pytest.raises(TypeError): - ca.issue_cert(comon_nam=u"wrong kwarg name") + ca.issue_cert(comon_nam=u"wrong kwarg name") # type: ignore[call-arg] # Must be unicode with pytest.raises(TypeError): - ca.issue_cert(common_name=b"bad kwarg value") + ca.issue_cert(common_name=b"bad kwarg value") # type: ignore[arg-type] # Default is no common name pem = ca.issue_cert(u"example.com").cert_chain_pems[0].bytes() @@ -487,7 +521,7 @@ def test_CN(): san = cert.extensions.get_extension_for_class( x509.SubjectAlternativeName ) - assert san.value[0] == x509.DNSName(u"example.com") + assert list(san.value)[0] == x509.DNSName(u"example.com") common_names = cert.subject.get_attributes_for_oid( x509.oid.NameOID.COMMON_NAME ) diff --git a/trustme/__init__.py b/trustme/__init__.py index bf6ef82..cc280b0 100644 --- a/trustme/__init__.py +++ b/trustme/__init__.py @@ -8,7 +8,7 @@ import os import ipaddress -import idna +import idna # type: ignore[import] from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -23,6 +23,12 @@ from ._version import __version__ +TYPE_CHECKING = False +if TYPE_CHECKING: # pragma: no cover + from typing import Generator, List, Optional, Text, Union + + import OpenSSL.SSL + __all__ = ["CA"] # Python 2/3 annoyingness @@ -47,6 +53,7 @@ DEFAULT_EXPIRY = datetime.datetime(2038, 1, 1) def _name(name, organization_name=None, common_name=None): + # type: (Text, Optional[Text], Optional[Text]) -> x509.Name name_pieces = [ x509.NameAttribute( NameOID.ORGANIZATION_NAME, @@ -62,14 +69,17 @@ def _name(name, organization_name=None, common_name=None): def random_text(): + # type: () -> Text return urlsafe_b64encode(os.urandom(12)).decode("ascii") def _smells_like_pyopenssl(ctx): - return getattr(ctx, "__module__", "").startswith("OpenSSL") + # type: (object) -> bool + return getattr(ctx, "__module__", "").startswith("OpenSSL") # type: ignore[no-any-return] def _cert_builder_common(subject, issuer, public_key, not_after=None): + # type: (x509.Name, x509.Name, rsa.RSAPublicKey, Optional[datetime.datetime]) -> x509.CertificateBuilder not_after = not_after if not_after else DEFAULT_EXPIRY return ( x509.CertificateBuilder() @@ -87,6 +97,7 @@ def _cert_builder_common(subject, issuer, public_key, not_after=None): def _identity_string_to_x509(identity): + # type: (Text) -> x509.GeneralName # Because we are a DWIM library for lazy slackers, we cheerfully pervert # the cryptography library's carefully type-safe API, and silently DTRT # for any of the following identity types: @@ -112,13 +123,13 @@ def _identity_string_to_x509(identity): # Have to try ip_address first, because ip_network("127.0.0.1") is # interpreted as being the network 127.0.0.1/32. Which I guess would be # fine, actually, but why risk it. - for ip_converter in [ipaddress.ip_address, ipaddress.ip_network]: + try: + return x509.IPAddress(ipaddress.ip_address(identity)) + except ValueError: try: - ip_hostname = ip_converter(identity) + return x509.IPAddress(ipaddress.ip_network(identity)) except ValueError: - continue - else: - return x509.IPAddress(ip_hostname) + pass # Encode to an A-label, like cryptography wants if identity.startswith("*."): @@ -140,15 +151,18 @@ class Blob(object): """ def __init__(self, data): + # type: (bytes) -> None self._data = data def bytes(self): + # type: () -> bytes """Returns the data as a `bytes` object. """ return self._data def write_to_path(self, path, append=False): + # type: (str, bool) -> None """Writes the data to the file at the given path. Args: @@ -166,6 +180,7 @@ def write_to_path(self, path, append=False): @contextmanager def tempfile(self, dir=None): + # type: (Optional[str]) -> Generator[str, None, None] """Context manager for writing data to a temporary file. The file is created when you enter the context manager, and @@ -195,7 +210,9 @@ def tempfile(self, dir=None): # open. Which seems like it completely defeats the purpose of having a # NamedTemporaryFile? Oh well... # https://bugs.python.org/issue14243 - f = NamedTemporaryFile(suffix=".pem", dir=dir, delete=False) + # Type ignore temporarily needed for Python 2: + # https://github.com/python/typeshed/pull/5836 + f = NamedTemporaryFile(suffix=".pem", dir=dir, delete=False) # type: ignore[arg-type] try: f.write(self._data) f.close() @@ -207,6 +224,9 @@ def tempfile(self, dir=None): class CA(object): """A certificate authority.""" + + _certificate = None # type: x509.Certificate + def __init__( self, parent_cert=None, @@ -214,6 +234,7 @@ def __init__( organization_name=None, organization_unit_name=None, ): + # type: (Optional[CA], int, Optional[Text], Optional[Text]) -> None self.parent_cert = parent_cert self._private_key = rsa.generate_private_key( public_exponent=65537, @@ -228,9 +249,10 @@ def __init__( ) issuer = name sign_key = self._private_key - if self.parent_cert is not None: + if parent_cert is not None: sign_key = parent_cert._private_key - issuer = parent_cert._certificate.subject + parent_certificate = parent_cert._certificate + issuer = parent_certificate.subject self._certificate = ( _cert_builder_common(name, issuer, self._private_key.public_key()) @@ -260,12 +282,14 @@ def __init__( @property def cert_pem(self): + # type: () -> Blob """`Blob`: The PEM-encoded certificate for this CA. Add this to your trust store to trust this CA.""" return Blob(self._certificate.public_bytes(Encoding.PEM)) @property def private_key_pem(self): + # type: () -> Blob """`Blob`: The PEM-encoded private key for this CA. Use this to sign other certificates from this CA.""" return Blob( @@ -277,6 +301,7 @@ def private_key_pem(self): ) def create_child_ca(self): + # type: () -> CA """Creates a child certificate authority Returns: @@ -292,6 +317,8 @@ def create_child_ca(self): return CA(parent_cert=self, path_length=path_length) def issue_cert(self, *identities, **kwargs): + # type: (Text, Optional[Union[Text, datetime.datetime]]) -> LeafCert + # PY3: (str, Optional[str], Optional[str], Optional[str], Optional[datetime.datetime]) -> LeafCert """issue_cert(*identities, common_name=None, organization_name=None, \ organization_unit_name=None, not_after=None) @@ -334,7 +361,7 @@ def issue_cert(self, *identities, **kwargs): organization_unit_name: Sets the "Organization Unit Name" (OU) attribute on the certificate. By default, a random one will be generated. - + not_after: Set the expiry date (notAfter) of the certificate. This argument type is `datetime.datetime`. @@ -342,10 +369,10 @@ def issue_cert(self, *identities, **kwargs): LeafCert: the newly-generated certificate. """ - common_name = kwargs.pop("common_name", None) - organization_name = kwargs.pop("organization_name", None) - organization_unit_name = kwargs.pop("organization_unit_name", None) - not_after = kwargs.pop("not_after", None) + common_name = kwargs.pop("common_name", None) # type: Optional[Text] # type: ignore[assignment] + organization_name = kwargs.pop("organization_name", None) # type: Optional[Text] # type: ignore[assignment] + organization_unit_name = kwargs.pop("organization_unit_name", None) # type: Optional[Text] # type: ignore[assignment] + not_after = kwargs.pop("not_after", None) # type: Optional[datetime.datetime] # type: ignore[assignment] if kwargs: raise TypeError("unrecognized keyword arguments {}".format(kwargs)) @@ -370,7 +397,7 @@ def issue_cert(self, *identities, **kwargs): aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski) except AttributeError: # The old way - aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski_ext) + aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski_ext) # type: ignore[arg-type] cert = ( _cert_builder_common( @@ -442,6 +469,7 @@ def issue_cert(self, *identities, **kwargs): issue_server_cert = issue_cert def configure_trust(self, ctx): + # type: (Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None """Configure the given context object to trust certificates signed by this CA. @@ -466,6 +494,7 @@ def configure_trust(self, ctx): @classmethod def from_pem(cls, cert_bytes, private_key_bytes): + # type: (bytes, bytes) -> CA """Build a CA from existing cert and private key. This is useful if your test suite has an existing certificate authority and @@ -504,6 +533,7 @@ class LeafCert(object): """ def __init__(self, private_key_pem, server_cert_pem, chain_to_ca): + # type: (bytes, bytes, List[bytes]) -> None self.private_key_pem = Blob(private_key_pem) self.cert_chain_pems = [ Blob(pem) for pem in [server_cert_pem] + chain_to_ca] @@ -511,6 +541,7 @@ def __init__(self, private_key_pem, server_cert_pem, chain_to_ca): Blob(private_key_pem + server_cert_pem + b''.join(chain_to_ca))) def configure_cert(self, ctx): + # type: (Union[ssl.SSLContext, OpenSSL.SSL.Context]) -> None """Configure the given context object to present this certificate. Args: diff --git a/trustme/_cli.py b/trustme/_cli.py index e1a6988..7c08d2c 100644 --- a/trustme/_cli.py +++ b/trustme/_cli.py @@ -7,6 +7,10 @@ from datetime import datetime +TYPE_CHECKING = False +if TYPE_CHECKING: # pragma: no cover + from typing import List, Optional + # Python 2/3 annoyingness try: unicode @@ -17,6 +21,7 @@ DATE_FORMAT = '%Y-%m-%d' def main(argv=None): + # type: (Optional[List[str]]) -> None if argv is None: argv = sys.argv[1:] diff --git a/trustme/py.typed b/trustme/py.typed new file mode 100644 index 0000000..e69de29