diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e726a4a..9795e33 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ --- repos: - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.3.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.13 + rev: v0.3.6 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -18,14 +18,13 @@ repos: args: [-L, fo] - repo: https://github.com/econchick/interrogate - rev: 1.5.0 + rev: 1.7.0 hooks: - id: interrogate - language_version: python3.11 - args: [tests, -v] + args: [tests] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/docs/conf.py b/docs/conf.py index 66ebecc..34dbd32 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,16 +1,6 @@ from importlib import metadata -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' - # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. @@ -45,9 +35,6 @@ # The suffix of source filenames. source_suffix = ".rst" -# The encoding of source files. -# source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = "index" @@ -68,44 +55,10 @@ if "dev" in release: release = version = "UNRELEASED" -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] -# The reST default role (used for this markup: `text`) to use for all -# documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -# pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - # -- Options for HTML output ---------------------------------------------- @@ -142,27 +95,6 @@ ) ] -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples @@ -177,10 +109,6 @@ ) ] -# If true, show URL addresses after external links. -# man_show_urls = False - - # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples @@ -198,18 +126,6 @@ ) ] -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# texinfo_no_detailmenu = False - intersphinx_mapping = { "python": ("https://docs.python.org/3", None), diff --git a/pyproject.toml b/pyproject.toml index b8cc0e3..ed6c4c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,43 +138,45 @@ line-length = 79 [tool.ruff] src = ["src", "tests"] -select = [ - "E", # pycodestyle - "W", # pycodestyle - "F", # Pyflakes - "UP", # pyupgrade - "N", # pep8-naming - "YTT", # flake8-2020 - "S", # flake8-bandit - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "T10", # flake8-debugger - "ISC", # flake8-implicit-str-concat - "RET", # flake8-return - "SIM", # flake8-simplify - "DTZ", # flake8-datetimez - "I", # isort - "PGH", # pygrep-hooks - "PLC", # Pylint - "PIE", # flake8-pie - "RUF", # ruff -] + +[tool.ruff.lint] +select = ["ALL"] ignore = [ - "RUF001", # leave my smart characters alone - "N801", # some artistic freedom when naming things after RFCs - "N802", # ditto + "A001", # shadowing is fine + "ANN", # Mypy is better at this + "ARG001", # we don't control all args passed in + "ARG005", # we need stub lambdas + "COM", # Black takes care of our commas + "D", # We prefer our own docstring style. + "E501", # leave line-length enforcement to Black + "FIX", # Yes, we want XXX as a marker. + "INP001", # sometimes we want Python files outside of packages + "N801", # some artistic freedom when naming things after RFCs + "N802", # ditto + "PLR2004", # numbers are sometimes fine + "RUF001", # leave my smart characters alone + "SLF001", # private members are accessed by friendly functions + "TCH", # TYPE_CHECKING blocks break autodocs + "TD", # we don't follow other people's todo style ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/*" = [ + "B018", # "useless" expressions can be useful in tests + "PLC1901", # empty strings are falsey, but are less specific in tests + "PT005", # we always add underscores and explicit name + "PT011", # broad is fine "S101", # assert "S301", # I know pickle is bad, but people use it. - "SIM300", # Yoda rocks in tests - "PLC1901", # empty strings are falsey, but are less specific in tests - "B018", # "useless" expressions can be useful in tests + "SIM300", # Yoda rocks in asserts + "TRY301", # tests need to raise exceptions +] +"docs/pyopenssl_example.py" = [ + "T201", # print is fine in the example + "T203", # pprint is fine in the example ] -[tool.ruff.isort] +[tool.ruff.lint.isort] lines-between-types = 1 lines-after-imports = 2 diff --git a/src/service_identity/__init__.py b/src/service_identity/__init__.py index d768c80..9c0c244 100644 --- a/src/service_identity/__init__.py +++ b/src/service_identity/__init__.py @@ -2,7 +2,6 @@ Verify service identities. """ - from . import cryptography, hazmat, pyopenssl from .exceptions import ( CertificateError, @@ -38,7 +37,8 @@ def __getattr__(name: str) -> str: "__email__": "", } if name not in dunder_to_metadata: - raise AttributeError(f"module {__name__} has no attribute {name}") + msg = f"module {__name__} has no attribute {name}" + raise AttributeError(msg) import warnings diff --git a/src/service_identity/cryptography.py b/src/service_identity/cryptography.py index 4585525..ef27f63 100644 --- a/src/service_identity/cryptography.py +++ b/src/service_identity/cryptography.py @@ -163,7 +163,8 @@ def extract_patterns(cert: Certificate) -> Sequence[CertificatePattern]: if isinstance(srv, IA5String): ids.append(SRVPattern.from_bytes(srv.asOctets())) else: # pragma: no cover - raise CertificateError("Unexpected certificate content.") + msg = "Unexpected certificate content." + raise CertificateError(msg) return ids diff --git a/src/service_identity/hazmat.py b/src/service_identity/hazmat.py index e8d5e75..694c387 100644 --- a/src/service_identity/hazmat.py +++ b/src/service_identity/hazmat.py @@ -51,9 +51,8 @@ def verify_service_identity( if a pattern of the respective type is present. """ if not cert_patterns: - raise CertificateError( - "Certificate does not contain any `subjectAltName`s." - ) + msg = "Certificate does not contain any `subjectAltName`s." + raise CertificateError(msg) errors = [] matches = _find_matches(cert_patterns, obligatory_ids) + _find_matches( @@ -63,7 +62,9 @@ def verify_service_identity( matched_ids = [match.service_id for match in matches] for i in obligatory_ids: if i not in matched_ids: - errors.append(i.error_on_mismatch(mismatched_id=i)) + errors.append( # noqa: PERF401 + i.error_on_mismatch(mismatched_id=i) + ) for i in optional_ids: # If an optional ID is not matched by a certificate pattern *but* there @@ -73,7 +74,9 @@ def verify_service_identity( if i not in matched_ids and _contains_instance_of( cert_patterns, i.pattern_class ): - errors.append(i.error_on_mismatch(mismatched_id=i)) + errors.append( # noqa: PERF401 + i.error_on_mismatch(mismatched_id=i) + ) if errors: raise VerificationError(errors=errors) @@ -95,7 +98,10 @@ def _find_matches( for sid in service_ids: for cid in cert_patterns: if sid.verify(cid): - matches.append(ServiceMatch(cert_pattern=cid, service_id=sid)) + matches.append( # noqa: PERF401 + ServiceMatch(cert_pattern=cid, service_id=sid) + ) + return matches @@ -121,9 +127,10 @@ def _is_ip_address(pattern: str | bytes) -> bool: try: int(pattern) - return True except ValueError: pass + else: + return True try: ipaddress.ip_address(pattern.replace("*", "1")) @@ -147,12 +154,14 @@ class DNSPattern: @classmethod def from_bytes(cls, pattern: bytes) -> DNSPattern: if not isinstance(pattern, bytes): - raise TypeError("The DNS pattern must be a bytes string.") + msg = "The DNS pattern must be a bytes string." + raise TypeError(msg) pattern = pattern.strip() if pattern == b"" or _is_ip_address(pattern) or b"\0" in pattern: - raise CertificateError(f"Invalid DNS pattern {pattern!r}.") + msg = f"Invalid DNS pattern {pattern!r}." + raise CertificateError(msg) pattern = pattern.translate(_TRANS_TO_LOWER) if b"*" in pattern: @@ -175,9 +184,8 @@ def from_bytes(cls, bs: bytes) -> IPAddressPattern: try: return cls(pattern=ipaddress.ip_address(bs)) except ValueError: - raise CertificateError( - f"Invalid IP address pattern {bs!r}." - ) from None + msg = f"Invalid IP address pattern {bs!r}." + raise CertificateError(msg) from None @attr.s(slots=True) @@ -194,12 +202,14 @@ class URIPattern: @classmethod def from_bytes(cls, pattern: bytes) -> URIPattern: if not isinstance(pattern, bytes): - raise TypeError("The URI pattern must be a bytes string.") + msg = "The URI pattern must be a bytes string." + raise TypeError(msg) pattern = pattern.strip().translate(_TRANS_TO_LOWER) if b":" not in pattern or b"*" in pattern or _is_ip_address(pattern): - raise CertificateError(f"Invalid URI pattern {pattern!r}.") + msg = f"Invalid URI pattern {pattern!r}." + raise CertificateError(msg) protocol_pattern, hostname = pattern.split(b":") @@ -223,7 +233,8 @@ class SRVPattern: @classmethod def from_bytes(cls, pattern: bytes) -> SRVPattern: if not isinstance(pattern, bytes): - raise TypeError("The SRV pattern must be a bytes string.") + msg = "The SRV pattern must be a bytes string." + raise TypeError(msg) pattern = pattern.strip().translate(_TRANS_TO_LOWER) @@ -233,7 +244,8 @@ def from_bytes(cls, pattern: bytes) -> SRVPattern: or b"*" in pattern or _is_ip_address(pattern) ): - raise CertificateError(f"Invalid SRV pattern {pattern!r}.") + msg = f"Invalid SRV pattern {pattern!r}." + raise CertificateError(msg) name, hostname = pattern.split(b".", 1) return cls( @@ -253,15 +265,12 @@ def from_bytes(cls, pattern: bytes) -> SRVPattern: @runtime_checkable class ServiceID(Protocol): @property - def pattern_class(self) -> type[CertificatePattern]: - ... + def pattern_class(self) -> type[CertificatePattern]: ... @property - def error_on_mismatch(self) -> type[Mismatch]: - ... + def error_on_mismatch(self) -> type[Mismatch]: ... - def verify(self, pattern: CertificatePattern) -> bool: - ... + def verify(self, pattern: CertificatePattern) -> bool: ... @attr.s(init=False, slots=True) @@ -279,25 +288,27 @@ class DNS_ID: def __init__(self, hostname: str): if not isinstance(hostname, str): - raise TypeError("DNS-ID must be a text string.") + msg = "DNS-ID must be a text string." + raise TypeError(msg) hostname = hostname.strip() if not hostname or _is_ip_address(hostname): - raise ValueError("Invalid DNS-ID.") + msg = "Invalid DNS-ID." + raise ValueError(msg) if any(ord(c) > 127 for c in hostname): if idna: ascii_id = idna.encode(hostname) else: - raise ImportError( - "idna library is required for non-ASCII IDs." - ) + msg = "idna library is required for non-ASCII IDs." + raise ImportError(msg) else: ascii_id = hostname.encode("ascii") self.hostname = ascii_id.translate(_TRANS_TO_LOWER) if self._RE_LEGAL_CHARS.match(self.hostname) is None: - raise ValueError("Invalid DNS-ID.") + msg = "Invalid DNS-ID." + raise ValueError(msg) def verify(self, pattern: CertificatePattern) -> bool: """ @@ -346,11 +357,13 @@ class URI_ID: def __init__(self, uri: str): if not isinstance(uri, str): - raise TypeError("URI-ID must be a text string.") + msg = "URI-ID must be a text string." + raise TypeError(msg) uri = uri.strip() if ":" not in uri or _is_ip_address(uri): - raise ValueError("Invalid URI-ID.") + msg = "Invalid URI-ID." + raise ValueError(msg) prot, hostname = uri.split(":") @@ -384,11 +397,13 @@ class SRV_ID: def __init__(self, srv: str): if not isinstance(srv, str): - raise TypeError("SRV-ID must be a text string.") + msg = "SRV-ID must be a text string." + raise TypeError(msg) srv = srv.strip() if "." not in srv or _is_ip_address(srv) or srv[0] != "_": - raise ValueError("Invalid SRV-ID.") + msg = "Invalid SRV-ID." + raise ValueError(msg) name, hostname = srv.split(".", 1) @@ -420,7 +435,7 @@ def _hostname_matches(cert_pattern: bytes, actual_hostname: bytes) -> bool: if actual_head.startswith(b"xn--"): return False - return cert_head == b"*" or cert_head == actual_head + return cert_head in (b"*", actual_head) return cert_pattern == actual_hostname @@ -432,25 +447,19 @@ def _validate_pattern(cert_pattern: bytes) -> None: """ cnt = cert_pattern.count(b"*") if cnt > 1: - raise CertificateError( - f"Certificate's DNS-ID {cert_pattern!r} contains too many wildcards." - ) + msg = f"Certificate's DNS-ID {cert_pattern!r} contains too many wildcards." + raise CertificateError(msg) parts = cert_pattern.split(b".") if len(parts) < 3: - raise CertificateError( - f"Certificate's DNS-ID {cert_pattern!r} has too few host components for " - "wildcard usage." - ) + msg = f"Certificate's DNS-ID {cert_pattern!r} has too few host components for wildcard usage." + raise CertificateError(msg) # We assume there will always be only one wildcard allowed. if b"*" not in parts[0]: - raise CertificateError( - "Certificate's DNS-ID {!r} has a wildcard outside the left-most " - "part.".format(cert_pattern) - ) + msg = f"Certificate's DNS-ID {cert_pattern!r} has a wildcard outside the left-most part." + raise CertificateError(msg) if any(not len(p) for p in parts): - raise CertificateError( - f"Certificate's DNS-ID {cert_pattern!r} contains empty parts." - ) + msg = f"Certificate's DNS-ID {cert_pattern!r} contains empty parts." + raise CertificateError(msg) # Ensure no locale magic interferes. diff --git a/src/service_identity/pyopenssl.py b/src/service_identity/pyopenssl.py index 0ed88bc..9e9fe5c 100644 --- a/src/service_identity/pyopenssl.py +++ b/src/service_identity/pyopenssl.py @@ -150,9 +150,8 @@ def extract_patterns(cert: X509) -> Sequence[CertificatePattern]: if isinstance(srv, IA5String): ids.append(SRVPattern.from_bytes(srv.asOctets())) else: # pragma: no cover - raise CertificateError( - "Unexpected certificate content." - ) + msg = "Unexpected certificate content." + raise CertificateError(msg) else: # pragma: no cover pass else: # pragma: no cover diff --git a/tests/test_hazmat.py b/tests/test_hazmat.py index 6c70f1f..d382b03 100644 --- a/tests/test_hazmat.py +++ b/tests/test_hazmat.py @@ -699,11 +699,11 @@ def test_repr_str(self): """ The __str__ and __repr__ methods return something helpful. """ - try: + with pytest.raises(VerificationError) as ei: raise VerificationError(errors=["foo"]) - except VerificationError as e: - assert repr(e) == str(e) - assert str(e) != "" + + assert repr(ei.value) == str(ei.value) + assert str(ei.value) != "" @pytest.mark.parametrize("proto", range(pickle.HIGHEST_PROTOCOL + 1)) @pytest.mark.parametrize( diff --git a/tests/typing/api.py b/tests/typing/api.py index d2836d6..731b169 100644 --- a/tests/typing/api.py +++ b/tests/typing/api.py @@ -20,9 +20,9 @@ backend = default_backend() c_cert = load_pem_x509_certificate("foo.pem", backend) -c_ids: Sequence[ - service_identity.hazmat.CertificatePattern -] = service_identity.cryptography.extract_patterns(c_cert) +c_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( + service_identity.cryptography.extract_patterns(c_cert) +) service_identity.cryptography.verify_certificate_hostname( c_cert, "example.com" ) @@ -36,8 +36,8 @@ p_cert = conn.get_peer_certificate() assert p_cert -p_ids: Sequence[ - service_identity.hazmat.CertificatePattern -] = service_identity.pyopenssl.extract_patterns(p_cert) +p_ids: Sequence[service_identity.hazmat.CertificatePattern] = ( + service_identity.pyopenssl.extract_patterns(p_cert) +) service_identity.pyopenssl.verify_hostname(conn, "example.com") service_identity.pyopenssl.verify_ip_address(conn, "127.0.0.1")