diff --git a/.coveragerc b/.coveragerc index 15791b3..f74bf58 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,4 +3,6 @@ exclude_lines = .* # Python \d.* .* # nocov: Python \d.* .* # pragma: no cover.* + ^\s*(?:el)?if t\.TYPE_CHECKING:$ + ^ +\.\.\.$ fail_under = 100 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20d21a3..29631e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,6 +19,10 @@ jobs: - os: windows-latest python: '3.12' toxenv: py + # typing + - os: ubuntu-latest + python: '3.8' + toxenv: typing # misc - os: ubuntu-latest python: '3.12' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 098a010..405e529 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,34 +1,31 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace -- repo: https://github.com/asottile/reorder-python-imports - rev: v3.12.0 +- repo: https://github.com/pycqa/isort + rev: 5.13.2 hooks: - - id: reorder-python-imports - args: [--application-directories, '.:src', --py37-plus] + - id: isort - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.4.2 hooks: - id: black - args: [--line-length=79] - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.16.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 exclude: ^(tests/|docs/|setup.py) additional_dependencies: - flake8-docstrings - - flake8-import-order - repo: https://github.com/asottile/setup-cfg-fmt rev: v2.5.0 hooks: diff --git a/docs/source/conf.py b/docs/source/conf.py index 5ee2603..163800d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,7 +19,6 @@ # sys.path.insert(0, os.path.abspath('.')) import rfc3986 - # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f2ffbab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.black] +line-length = 79 +target-version = ["py38"] + +[tool.isort] +profile = "black" +line_length = 79 +force_single_line = true + +[tool.pyright] +include = ["src/rfc3986"] +ignore = ["tests"] +pythonVersion = "3.8" +typeCheckingMode = "strict" + +reportPrivateUsage = "none" +reportImportCycles = "warning" +reportPropertyTypeMismatch = "warning" +reportUnnecessaryTypeIgnoreComment = "warning" diff --git a/src/rfc3986/__init__.py b/src/rfc3986/__init__.py index bc2da11..e71de1f 100644 --- a/src/rfc3986/__init__.py +++ b/src/rfc3986/__init__.py @@ -19,12 +19,12 @@ :copyright: (c) 2014 Rackspace :license: Apache v2.0, see LICENSE for details """ -from .api import iri_reference from .api import IRIReference +from .api import URIReference +from .api import iri_reference from .api import is_valid_uri from .api import normalize_uri from .api import uri_reference -from .api import URIReference from .api import urlparse from .parseresult import ParseResult diff --git a/src/rfc3986/_mixin.py b/src/rfc3986/_mixin.py index d7d3589..b03b772 100644 --- a/src/rfc3986/_mixin.py +++ b/src/rfc3986/_mixin.py @@ -1,18 +1,36 @@ """Module containing the implementation of the URIMixin class.""" + +import typing as t import warnings from . import exceptions as exc from . import misc from . import normalizers +from . import uri from . import validators +from ._typing_compat import Self as _Self + + +class _AuthorityInfo(t.TypedDict): + """A typed dict for the authority info triple: userinfo, host, and port.""" + + userinfo: t.Optional[str] + host: t.Optional[str] + port: t.Optional[str] class URIMixin: """Mixin with all shared methods for URIs and IRIs.""" - __hash__ = tuple.__hash__ + if t.TYPE_CHECKING: + scheme: t.Optional[str] + authority: t.Optional[str] + path: t.Optional[str] + query: t.Optional[str] + fragment: t.Optional[str] + encoding: str - def authority_info(self): + def authority_info(self) -> _AuthorityInfo: """Return a dictionary with the ``userinfo``, ``host``, and ``port``. If the authority is not valid, it will raise a @@ -53,11 +71,11 @@ def authority_info(self): return matches - def _match_subauthority(self): + def _match_subauthority(self) -> t.Optional[t.Match[str]]: return misc.SUBAUTHORITY_MATCHER.match(self.authority) @property - def _validator(self): + def _validator(self) -> validators.Validator: v = getattr(self, "_cached_validator", None) if v is not None: return v @@ -67,7 +85,7 @@ def _validator(self): return self._cached_validator @property - def host(self): + def host(self) -> t.Optional[str]: """If present, a string representing the host.""" try: authority = self.authority_info() @@ -76,7 +94,7 @@ def host(self): return authority["host"] @property - def port(self): + def port(self) -> t.Optional[str]: """If present, the port extracted from the authority.""" try: authority = self.authority_info() @@ -85,7 +103,7 @@ def port(self): return authority["port"] @property - def userinfo(self): + def userinfo(self) -> t.Optional[str]: """If present, the userinfo extracted from the authority.""" try: authority = self.authority_info() @@ -93,7 +111,7 @@ def userinfo(self): return None return authority["userinfo"] - def is_absolute(self): + def is_absolute(self) -> bool: """Determine if this URI Reference is an absolute URI. See http://tools.ietf.org/html/rfc3986#section-4.3 for explanation. @@ -103,7 +121,7 @@ def is_absolute(self): """ return bool(misc.ABSOLUTE_URI_MATCHER.match(self.unsplit())) - def is_valid(self, **kwargs): + def is_valid(self, **kwargs: bool) -> bool: """Determine if the URI is valid. .. deprecated:: 1.1.0 @@ -137,7 +155,7 @@ def is_valid(self, **kwargs): ] return all(v(r) for v, r in validators) - def authority_is_valid(self, require=False): + def authority_is_valid(self, require: bool = False) -> bool: """Determine if the authority component is valid. .. deprecated:: 1.1.0 @@ -167,7 +185,7 @@ def authority_is_valid(self, require=False): require=require, ) - def scheme_is_valid(self, require=False): + def scheme_is_valid(self, require: bool = False) -> bool: """Determine if the scheme component is valid. .. deprecated:: 1.1.0 @@ -186,7 +204,7 @@ def scheme_is_valid(self, require=False): ) return validators.scheme_is_valid(self.scheme, require) - def path_is_valid(self, require=False): + def path_is_valid(self, require: bool = False) -> bool: """Determine if the path component is valid. .. deprecated:: 1.1.0 @@ -205,7 +223,7 @@ def path_is_valid(self, require=False): ) return validators.path_is_valid(self.path, require) - def query_is_valid(self, require=False): + def query_is_valid(self, require: bool = False) -> bool: """Determine if the query component is valid. .. deprecated:: 1.1.0 @@ -224,7 +242,7 @@ def query_is_valid(self, require=False): ) return validators.query_is_valid(self.query, require) - def fragment_is_valid(self, require=False): + def fragment_is_valid(self, require: bool = False) -> bool: """Determine if the fragment component is valid. .. deprecated:: 1.1.0 @@ -243,7 +261,7 @@ def fragment_is_valid(self, require=False): ) return validators.fragment_is_valid(self.fragment, require) - def normalized_equality(self, other_ref): + def normalized_equality(self, other_ref: "uri.URIReference") -> bool: """Compare this URIReference to another URIReference. :param URIReference other_ref: (required), The reference with which @@ -253,7 +271,11 @@ def normalized_equality(self, other_ref): """ return tuple(self.normalize()) == tuple(other_ref.normalize()) - def resolve_with(self, base_uri, strict=False): + def resolve_with( # noqa: C901 + self, + base_uri: t.Union[str, "uri.URIReference"], + strict: bool = False, + ) -> _Self: """Use an absolute URI Reference to resolve this relative reference. Assuming this is a relative reference that you would like to resolve, @@ -272,6 +294,9 @@ def resolve_with(self, base_uri, strict=False): if not isinstance(base_uri, URIMixin): base_uri = type(self).from_string(base_uri) + if t.TYPE_CHECKING: + base_uri = t.cast(uri.URIReference, base_uri) + try: self._validator.validate(base_uri) except exc.ValidationError: @@ -325,14 +350,14 @@ def resolve_with(self, base_uri, strict=False): ) return target - def unsplit(self): + def unsplit(self) -> str: """Create a URI string from the components. :returns: The URI Reference reconstituted as a string. :rtype: str """ # See http://tools.ietf.org/html/rfc3986#section-5.3 - result_list = [] + result_list: list[str] = [] if self.scheme: result_list.extend([self.scheme, ":"]) if self.authority: @@ -347,12 +372,12 @@ def unsplit(self): def copy_with( self, - scheme=misc.UseExisting, - authority=misc.UseExisting, - path=misc.UseExisting, - query=misc.UseExisting, - fragment=misc.UseExisting, - ): + scheme: t.Optional[str] = misc.UseExisting, + authority: t.Optional[str] = misc.UseExisting, + path: t.Optional[str] = misc.UseExisting, + query: t.Optional[str] = misc.UseExisting, + fragment: t.Optional[str] = misc.UseExisting, + ) -> _Self: """Create a copy of this reference with the new components. :param str scheme: @@ -380,6 +405,6 @@ def copy_with( for key, value in list(attributes.items()): if value is misc.UseExisting: del attributes[key] - uri = self._replace(**attributes) + uri: _Self = self._replace(**attributes) uri.encoding = self.encoding return uri diff --git a/src/rfc3986/_typing_compat.py b/src/rfc3986/_typing_compat.py new file mode 100644 index 0000000..5e4e36c --- /dev/null +++ b/src/rfc3986/_typing_compat.py @@ -0,0 +1,19 @@ +import sys +import typing as t + +__all__ = ("Self",) + +if sys.version_info >= (3, 11): # pragma: no cover + from typing import Self +elif t.TYPE_CHECKING: + from typing_extensions import Self +else: # pragma: no cover + + class _PlaceholderMeta(type): + # This is meant to make it easier to debug the presence of placeholder + # classes. + def __repr__(self): + return f"placeholder for typing.{self.__name__}" + + class Self(metaclass=_PlaceholderMeta): + """Placeholder for "typing.Self".""" diff --git a/src/rfc3986/api.py b/src/rfc3986/api.py index e8a54dd..64bd957 100644 --- a/src/rfc3986/api.py +++ b/src/rfc3986/api.py @@ -17,12 +17,17 @@ This module defines functions and provides access to the public attributes and classes of rfc3986. """ +import typing as t + from .iri import IRIReference from .parseresult import ParseResult from .uri import URIReference -def uri_reference(uri, encoding="utf-8"): +def uri_reference( + uri: t.Union[str, bytes], + encoding: str = "utf-8", +) -> URIReference: """Parse a URI string into a URIReference. This is a convenience function. You could achieve the same end by using @@ -36,7 +41,10 @@ def uri_reference(uri, encoding="utf-8"): return URIReference.from_string(uri, encoding) -def iri_reference(iri, encoding="utf-8"): +def iri_reference( + iri: t.Union[str, bytes], + encoding: str = "utf-8", +) -> IRIReference: """Parse a IRI string into an IRIReference. This is a convenience function. You could achieve the same end by using @@ -50,7 +58,11 @@ def iri_reference(iri, encoding="utf-8"): return IRIReference.from_string(iri, encoding) -def is_valid_uri(uri, encoding="utf-8", **kwargs): +def is_valid_uri( + uri: t.Union[str, bytes], + encoding: str = "utf-8", + **kwargs: bool, +) -> bool: """Determine if the URI given is valid. This is a convenience function. You could use either @@ -75,7 +87,7 @@ def is_valid_uri(uri, encoding="utf-8", **kwargs): return URIReference.from_string(uri, encoding).is_valid(**kwargs) -def normalize_uri(uri, encoding="utf-8"): +def normalize_uri(uri: t.Union[str, bytes], encoding: str = "utf-8") -> str: """Normalize the given URI. This is a convenience function. You could use either @@ -91,7 +103,7 @@ def normalize_uri(uri, encoding="utf-8"): return normalized_reference.unsplit() -def urlparse(uri, encoding="utf-8"): +def urlparse(uri: t.Union[str, bytes], encoding: str = "utf-8") -> ParseResult: """Parse a given URI and return a ParseResult. This is a partial replacement of the standard library's urlparse function. diff --git a/src/rfc3986/builder.py b/src/rfc3986/builder.py index 2826b74..68789db 100644 --- a/src/rfc3986/builder.py +++ b/src/rfc3986/builder.py @@ -12,12 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. """Module containing the logic for the URIBuilder object.""" +import typing as t from urllib.parse import parse_qsl from urllib.parse import urlencode from . import normalizers from . import uri from . import uri_reference +from ._typing_compat import Self as _Self + +# Modified from urllib.parse in typeshed. +_QueryType = t.Union[ + t.Mapping[t.Any, t.Any], + t.Mapping[t.Any, t.Sequence[t.Any]], + # Substituting List for Sequence since one of the add/extend methods + # below has a runtime isinstance check for list. + t.List[t.Tuple[t.Any, t.Any]], + t.List[t.Tuple[t.Any, t.Sequence[t.Any]]], +] class URIBuilder: @@ -33,13 +45,13 @@ class URIBuilder: def __init__( self, - scheme=None, - userinfo=None, - host=None, - port=None, - path=None, - query=None, - fragment=None, + scheme: t.Optional[str] = None, + userinfo: t.Optional[str] = None, + host: t.Optional[str] = None, + port: t.Optional[t.Union[int, str]] = None, + path: t.Optional[str] = None, + query: t.Optional[str] = None, + fragment: t.Optional[str] = None, ): """Initialize our URI builder. @@ -49,7 +61,7 @@ def __init__( (optional) :param str host: (optional) - :param int port: + :param int | str port: (optional) :param str path: (optional) @@ -61,7 +73,7 @@ def __init__( self.scheme = scheme self.userinfo = userinfo self.host = host - self.port = port + self.port = str(port) if port is not None else port self.path = path self.query = query self.fragment = fragment @@ -76,7 +88,7 @@ def __repr__(self): return formatstr.format(b=self) @classmethod - def from_uri(cls, reference): + def from_uri(cls, reference: t.Union["uri.URIReference", str]) -> _Self: """Initialize the URI builder from another URI. Takes the given URI reference and creates a new URI builder instance @@ -95,7 +107,7 @@ def from_uri(cls, reference): fragment=reference.fragment, ) - def add_scheme(self, scheme): + def add_scheme(self, scheme: str) -> "URIBuilder": """Add a scheme to our builder object. After normalizing, this will generate a new URIBuilder instance with @@ -119,7 +131,11 @@ def add_scheme(self, scheme): fragment=self.fragment, ) - def add_credentials(self, username, password): + def add_credentials( + self, + username: str, + password: t.Optional[str], + ) -> "URIBuilder": """Add credentials as the userinfo portion of the URI. .. code-block:: python @@ -152,7 +168,7 @@ def add_credentials(self, username, password): fragment=self.fragment, ) - def add_host(self, host): + def add_host(self, host: str) -> "URIBuilder": """Add hostname to the URI. .. code-block:: python @@ -172,7 +188,7 @@ def add_host(self, host): fragment=self.fragment, ) - def add_port(self, port): + def add_port(self, port: t.Union[int, str]) -> "URIBuilder": """Add port to the URI. .. code-block:: python @@ -211,7 +227,7 @@ def add_port(self, port): fragment=self.fragment, ) - def add_path(self, path): + def add_path(self, path: str) -> "URIBuilder": """Add a path to the URI. .. code-block:: python @@ -238,7 +254,7 @@ def add_path(self, path): fragment=self.fragment, ) - def extend_path(self, path): + def extend_path(self, path: str) -> "URIBuilder": """Extend the existing path value with the provided value. .. versionadded:: 1.5.0 @@ -267,7 +283,7 @@ def extend_path(self, path): return self.add_path(path) - def add_query_from(self, query_items): + def add_query_from(self, query_items: _QueryType) -> "URIBuilder": """Generate and add a query a dictionary or list of tuples. .. code-block:: python @@ -293,7 +309,7 @@ def add_query_from(self, query_items): fragment=self.fragment, ) - def extend_query_with(self, query_items): + def extend_query_with(self, query_items: _QueryType) -> "URIBuilder": """Extend the existing query string with the new query items. .. versionadded:: 1.5.0 @@ -314,7 +330,7 @@ def extend_query_with(self, query_items): return self.add_query_from(original_query_items + query_items) - def add_query(self, query): + def add_query(self, query: str) -> "URIBuilder": """Add a pre-formated query string to the URI. .. code-block:: python @@ -334,7 +350,7 @@ def add_query(self, query): fragment=self.fragment, ) - def add_fragment(self, fragment): + def add_fragment(self, fragment: str) -> "URIBuilder": """Add a fragment to the URI. .. code-block:: python @@ -354,7 +370,7 @@ def add_fragment(self, fragment): fragment=normalizers.normalize_fragment(fragment), ) - def finalize(self): + def finalize(self) -> "uri.URIReference": """Create a URIReference from our builder. .. code-block:: python @@ -379,7 +395,7 @@ def finalize(self): self.fragment, ) - def geturl(self): + def geturl(self) -> str: """Generate the URL from this builder. .. versionadded:: 1.5.0 diff --git a/src/rfc3986/compat.py b/src/rfc3986/compat.py index 9c1dca9..b66e1db 100644 --- a/src/rfc3986/compat.py +++ b/src/rfc3986/compat.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Compatibility module for Python 2 and 3 support.""" +import typing as t __all__ = ( "to_bytes", @@ -19,14 +20,44 @@ ) -def to_str(b, encoding="utf-8"): +@t.overload +def to_str( # noqa: D103 + b: t.Union[str, bytes], + encoding: str = "utf-8", +) -> str: ... + + +@t.overload +def to_str(b: None, encoding: str = "utf-8") -> None: # noqa: D103 + ... + + +def to_str( + b: t.Optional[t.Union[str, bytes]], + encoding: str = "utf-8", +) -> t.Optional[str]: """Ensure that b is text in the specified encoding.""" if hasattr(b, "decode") and not isinstance(b, str): b = b.decode(encoding) return b -def to_bytes(s, encoding="utf-8"): +@t.overload +def to_bytes( # noqa: D103 + s: t.Union[str, bytes], + encoding: str = "utf-8", +) -> bytes: ... + + +@t.overload +def to_bytes(s: None, encoding: str = "utf-8") -> None: # noqa: D103 + ... + + +def to_bytes( + s: t.Optional[t.Union[str, bytes]], + encoding: str = "utf-8", +) -> t.Optional[bytes]: """Ensure that s is converted to bytes from the encoding.""" if hasattr(s, "encode") and not isinstance(s, bytes): s = s.encode(encoding) diff --git a/src/rfc3986/exceptions.py b/src/rfc3986/exceptions.py index d513ddc..d0e853b 100644 --- a/src/rfc3986/exceptions.py +++ b/src/rfc3986/exceptions.py @@ -1,5 +1,9 @@ """Exceptions module for rfc3986.""" + +import typing as t + from . import compat +from . import uri class RFC3986Exception(Exception): @@ -11,7 +15,7 @@ class RFC3986Exception(Exception): class InvalidAuthority(RFC3986Exception): """Exception when the authority string is invalid.""" - def __init__(self, authority): + def __init__(self, authority: t.Union[str, bytes]) -> None: """Initialize the exception with the invalid authority.""" super().__init__( f"The authority ({compat.to_str(authority)}) is not valid." @@ -21,7 +25,7 @@ def __init__(self, authority): class InvalidPort(RFC3986Exception): """Exception when the port is invalid.""" - def __init__(self, port): + def __init__(self, port: str) -> None: """Initialize the exception with the invalid port.""" super().__init__(f'The port ("{port}") is not valid.') @@ -29,7 +33,7 @@ def __init__(self, port): class ResolutionError(RFC3986Exception): """Exception to indicate a failure to resolve a URI.""" - def __init__(self, uri): + def __init__(self, uri: "uri.URIReference") -> None: """Initialize the error with the failed URI.""" super().__init__( "{} does not meet the requirements for resolution.".format( @@ -47,7 +51,7 @@ class ValidationError(RFC3986Exception): class MissingComponentError(ValidationError): """Exception raised when a required component is missing.""" - def __init__(self, uri, *component_names): + def __init__(self, uri: "uri.URIReference", *component_names: str) -> None: """Initialize the error with the missing component name.""" verb = "was" if len(component_names) > 1: @@ -66,7 +70,12 @@ def __init__(self, uri, *component_names): class UnpermittedComponentError(ValidationError): """Exception raised when a component has an unpermitted value.""" - def __init__(self, component_name, component_value, allowed_values): + def __init__( + self, + component_name: str, + component_value: t.Any, + allowed_values: t.Collection[t.Any], + ) -> None: """Initialize the error with the unpermitted component.""" super().__init__( "{} was required to be one of {!r} but was {!r}".format( @@ -86,7 +95,7 @@ def __init__(self, component_name, component_value, allowed_values): class PasswordForbidden(ValidationError): """Exception raised when a URL has a password in the userinfo section.""" - def __init__(self, uri): + def __init__(self, uri: t.Union[str, "uri.URIReference"]) -> None: """Initialize the error with the URI that failed validation.""" unsplit = getattr(uri, "unsplit", lambda: uri) super().__init__( @@ -100,7 +109,7 @@ def __init__(self, uri): class InvalidComponentsError(ValidationError): """Exception raised when one or more components are invalid.""" - def __init__(self, uri, *component_names): + def __init__(self, uri: "uri.URIReference", *component_names: str) -> None: """Initialize the error with the invalid component name(s).""" verb = "was" if len(component_names) > 1: diff --git a/src/rfc3986/iri.py b/src/rfc3986/iri.py index 363d6e6..205221e 100644 --- a/src/rfc3986/iri.py +++ b/src/rfc3986/iri.py @@ -1,4 +1,5 @@ """Module containing the implementation of the IRIReference class.""" + # Copyright (c) 2014 Rackspace # Copyright (c) 2015 Ian Stapleton Cordasco # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,14 +14,14 @@ # implied. # See the License for the specific language governing permissions and # limitations under the License. -from collections import namedtuple +import typing as t from . import compat from . import exceptions from . import misc from . import normalizers from . import uri - +from ._typing_compat import Self as _Self try: import idna @@ -28,9 +29,7 @@ idna = None -class IRIReference( - namedtuple("IRIReference", misc.URI_COMPONENTS), uri.URIMixin -): +class IRIReference(misc.URIReferenceBase, uri.URIMixin): """Immutable object representing a parsed IRI Reference. Can be encoded into an URIReference object via the procedure @@ -41,11 +40,17 @@ class IRIReference( the future. Check for changes to the interface when upgrading. """ - slots = () + encoding: str def __new__( - cls, scheme, authority, path, query, fragment, encoding="utf-8" - ): + cls, + scheme: t.Optional[str], + authority: t.Optional[str], + path: t.Optional[str], + query: t.Optional[str], + fragment: t.Optional[str], + encoding: str = "utf-8", + ) -> _Self: """Create a new IRIReference.""" ref = super().__new__( cls, @@ -58,14 +63,16 @@ def __new__( ref.encoding = encoding return ref - def __eq__(self, other): + __hash__ = tuple.__hash__ + + def __eq__(self, other: object) -> bool: """Compare this reference to another.""" other_ref = other if isinstance(other, tuple): - other_ref = self.__class__(*other) + other_ref = type(self)(*other) elif not isinstance(other, IRIReference): try: - other_ref = self.__class__.from_string(other) + other_ref = self.from_string(other) except TypeError: raise TypeError( "Unable to compare {}() to {}()".format( @@ -76,11 +83,15 @@ def __eq__(self, other): # See http://tools.ietf.org/html/rfc3986#section-6.2 return tuple(self) == tuple(other_ref) - def _match_subauthority(self): + def _match_subauthority(self) -> t.Optional[t.Match[str]]: return misc.ISUBAUTHORITY_MATCHER.match(self.authority) @classmethod - def from_string(cls, iri_string, encoding="utf-8"): + def from_string( + cls, + iri_string: t.Union[str, bytes], + encoding: str = "utf-8", + ) -> _Self: """Parse a IRI reference from the given unicode IRI string. :param str iri_string: Unicode IRI to be parsed into a reference. @@ -99,7 +110,12 @@ def from_string(cls, iri_string, encoding="utf-8"): encoding, ) - def encode(self, idna_encoder=None): # noqa: C901 + def encode( # noqa: C901 + self, + idna_encoder: t.Optional[ # pyright: ignore[reportRedeclaration] + t.Callable[[str], t.Union[str, bytes]] + ] = None, + ) -> "uri.URIReference": """Encode an IRIReference into a URIReference instance. If the ``idna`` module is installed or the ``rfc3986[idna]`` @@ -122,7 +138,9 @@ def encode(self, idna_encoder=None): # noqa: C901 "and the IRI hostname requires encoding" ) - def idna_encoder(name): + def idna_encoder(name: str) -> t.Union[str, bytes]: + assert idna # Known to not be None at this point. + if any(ord(c) > 128 for c in name): try: return idna.encode( diff --git a/src/rfc3986/misc.py b/src/rfc3986/misc.py index 7cbbbec..10d0ecb 100644 --- a/src/rfc3986/misc.py +++ b/src/rfc3986/misc.py @@ -18,12 +18,20 @@ expressions for parsing and validating URIs and their components. """ import re +import typing as t from . import abnf_regexp -# These are enumerated for the named tuple used as a superclass of -# URIReference -URI_COMPONENTS = ["scheme", "authority", "path", "query", "fragment"] + +class URIReferenceBase(t.NamedTuple): + """The namedtuple used as a superclass of URIReference and IRIReference.""" + + scheme: t.Optional[str] + authority: t.Optional[str] + path: t.Optional[str] + query: t.Optional[str] + fragment: t.Optional[str] + important_characters = { "generic_delimiters": abnf_regexp.GENERIC_DELIMITERS, @@ -118,7 +126,7 @@ # Path merger as defined in http://tools.ietf.org/html/rfc3986#section-5.2.3 -def merge_paths(base_uri, relative_path): +def merge_paths(base_uri: URIReferenceBase, relative_path: str) -> str: """Merge a base URI's path with a relative URI's path.""" if base_uri.path is None and base_uri.authority is not None: return "/" + relative_path @@ -128,4 +136,4 @@ def merge_paths(base_uri, relative_path): return path[:index] + "/" + relative_path -UseExisting = object() +UseExisting: t.Final[t.Any] = object() diff --git a/src/rfc3986/normalizers.py b/src/rfc3986/normalizers.py index c989201..532bfaf 100644 --- a/src/rfc3986/normalizers.py +++ b/src/rfc3986/normalizers.py @@ -13,18 +13,21 @@ # limitations under the License. """Module with functions to normalize components.""" import re +import typing as t from urllib.parse import quote as urlquote from . import compat from . import misc -def normalize_scheme(scheme): +def normalize_scheme(scheme: str) -> str: """Normalize the scheme component.""" return scheme.lower() -def normalize_authority(authority): +def normalize_authority( + authority: t.Tuple[t.Optional[str], t.Optional[str], t.Optional[str]], +) -> str: """Normalize an authority tuple to a string.""" userinfo, host, port = authority result = "" @@ -37,17 +40,17 @@ def normalize_authority(authority): return result -def normalize_username(username): +def normalize_username(username: str) -> str: """Normalize a username to make it safe to include in userinfo.""" return urlquote(username) -def normalize_password(password): +def normalize_password(password: str) -> str: """Normalize a password to make safe for userinfo.""" return urlquote(password) -def normalize_host(host): +def normalize_host(host: str) -> str: """Normalize a host string.""" if misc.IPv6_MATCHER.match(host): percent = host.find("%") @@ -70,7 +73,7 @@ def normalize_host(host): return host.lower() -def normalize_path(path): +def normalize_path(path: str) -> str: """Normalize the path string.""" if not path: return path @@ -79,14 +82,34 @@ def normalize_path(path): return remove_dot_segments(path) -def normalize_query(query): +@t.overload +def normalize_query(query: str) -> str: # noqa: D103 + ... + + +@t.overload +def normalize_query(query: None) -> None: # noqa: D103 + ... + + +def normalize_query(query: t.Optional[str]) -> t.Optional[str]: """Normalize the query string.""" if not query: return query return normalize_percent_characters(query) -def normalize_fragment(fragment): +@t.overload +def normalize_fragment(fragment: str) -> str: # noqa: D103 + ... + + +@t.overload +def normalize_fragment(fragment: None) -> None: # noqa: D103 + ... + + +def normalize_fragment(fragment: t.Optional[str]) -> t.Optional[str]: """Normalize the fragment string.""" if not fragment: return fragment @@ -96,7 +119,7 @@ def normalize_fragment(fragment): PERCENT_MATCHER = re.compile("%[A-Fa-f0-9]{2}") -def normalize_percent_characters(s): +def normalize_percent_characters(s: str) -> str: """All percent characters should be upper-cased. For example, ``"%3afoo%DF%ab"`` should be turned into ``"%3Afoo%DF%AB"``. @@ -108,14 +131,14 @@ def normalize_percent_characters(s): return s -def remove_dot_segments(s): +def remove_dot_segments(s: str) -> str: """Remove dot segments from the string. See also Section 5.2.4 of :rfc:`3986`. """ # See http://tools.ietf.org/html/rfc3986#section-5.2.4 for pseudo-code segments = s.split("/") # Turn the path into a list of segments - output = [] # Initialize the variable to use to store output + output: list[str] = [] # Initialize the variable to use to store output for segment in segments: # '.' is the current directory, so ignore it, it is superfluous @@ -142,7 +165,20 @@ def remove_dot_segments(s): return "/".join(output) -def encode_component(uri_component, encoding): +@t.overload +def encode_component(uri_component: None, encoding: str) -> None: # noqa: D103 + ... + + +@t.overload +def encode_component(uri_component: str, encoding: str) -> str: # noqa: D103 + ... + + +def encode_component( + uri_component: t.Optional[str], + encoding: str, +) -> t.Optional[str]: """Encode the specific component in the provided encoding.""" if uri_component is None: return uri_component diff --git a/src/rfc3986/parseresult.py b/src/rfc3986/parseresult.py index b69432d..80cc300 100644 --- a/src/rfc3986/parseresult.py +++ b/src/rfc3986/parseresult.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Module containing the urlparse compatibility logic.""" +import typing as t from collections import namedtuple from . import compat @@ -19,6 +20,7 @@ from . import misc from . import normalizers from . import uri +from ._typing_compat import Self as _Self __all__ = ("ParseResult", "ParseResultBytes") @@ -33,8 +35,21 @@ ) -class ParseResultMixin: - def _generate_authority(self, attributes): +class ParseResultMixin(t.Generic[t.AnyStr]): + if t.TYPE_CHECKING: + userinfo: t.Optional[t.AnyStr] + host: t.Optional[t.AnyStr] + port: t.Optional[int] + query: t.Optional[t.AnyStr] + encoding: str + + @property + def authority(self) -> t.Optional[t.AnyStr]: ... + + def _generate_authority( + self, + attributes: t.Dict[str, t.Optional[t.AnyStr]], + ) -> t.Optional[str]: # I swear I did not align the comparisons below. That's just how they # happened to align based on pep8 and attribute lengths. userinfo, host, port = ( @@ -54,28 +69,28 @@ def _generate_authority(self, attributes): return self.authority.decode("utf-8") return self.authority - def geturl(self): + def geturl(self) -> t.AnyStr: """Shim to match the standard library method.""" return self.unsplit() @property - def hostname(self): + def hostname(self) -> t.Optional[t.AnyStr]: """Shim to match the standard library.""" return self.host @property - def netloc(self): + def netloc(self) -> t.Optional[t.AnyStr]: """Shim to match the standard library.""" return self.authority @property - def params(self): + def params(self) -> t.Optional[t.AnyStr]: """Shim to match the standard library.""" return self.query class ParseResult( - namedtuple("ParseResult", PARSED_COMPONENTS), ParseResultMixin + namedtuple("ParseResult", PARSED_COMPONENTS), ParseResultMixin[str] ): """Implementation of urlparse compatibility class. @@ -83,20 +98,28 @@ class ParseResult( urlparse.ParseResult class. """ - slots = () + scheme: t.Optional[str] + userinfo: t.Optional[str] + host: t.Optional[str] + port: t.Optional[int] + path: t.Optional[str] + query: t.Optional[str] + fragment: t.Optional[str] + encoding: str + reference: "uri.URIReference" def __new__( cls, - scheme, - userinfo, - host, - port, - path, - query, - fragment, - uri_ref, - encoding="utf-8", - ): + scheme: t.Optional[str], + userinfo: t.Optional[str], + host: t.Optional[str], + port: t.Optional[int], + path: t.Optional[str], + query: t.Optional[str], + fragment: t.Optional[str], + uri_ref: "uri.URIReference", + encoding: str = "utf-8", + ) -> _Self: """Create a new ParseResult.""" parse_result = super().__new__( cls, @@ -115,15 +138,15 @@ def __new__( @classmethod def from_parts( cls, - scheme=None, - userinfo=None, - host=None, - port=None, - path=None, - query=None, - fragment=None, - encoding="utf-8", - ): + scheme: t.Optional[str] = None, + userinfo: t.Optional[str] = None, + host: t.Optional[str] = None, + port: t.Optional[t.Union[int, str]] = None, + path: t.Optional[str] = None, + query: t.Optional[str] = None, + fragment: t.Optional[str] = None, + encoding: str = "utf-8", + ) -> _Self: """Create a ParseResult instance from its parts.""" authority = "" if userinfo is not None: @@ -155,8 +178,12 @@ def from_parts( @classmethod def from_string( - cls, uri_string, encoding="utf-8", strict=True, lazy_normalize=True - ): + cls, + uri_string: t.Union[str, bytes], + encoding: str = "utf-8", + strict: bool = True, + lazy_normalize: bool = True, + ) -> _Self: """Parse a URI from the given unicode URI string. :param str uri_string: Unicode URI to be parsed into a reference. @@ -184,26 +211,26 @@ def from_string( ) @property - def authority(self): + def authority(self) -> t.Optional[str]: """Return the normalized authority.""" return self.reference.authority def copy_with( self, - scheme=misc.UseExisting, - userinfo=misc.UseExisting, - host=misc.UseExisting, - port=misc.UseExisting, - path=misc.UseExisting, - query=misc.UseExisting, - fragment=misc.UseExisting, - ): + scheme: t.Optional[str] = misc.UseExisting, + userinfo: t.Optional[str] = misc.UseExisting, + host: t.Optional[str] = misc.UseExisting, + port: t.Optional[t.Union[int, str]] = misc.UseExisting, + path: t.Optional[str] = misc.UseExisting, + query: t.Optional[str] = misc.UseExisting, + fragment: t.Optional[str] = misc.UseExisting, + ) -> "ParseResult": """Create a copy of this instance replacing with specified parts.""" attributes = zip( PARSED_COMPONENTS, (scheme, userinfo, host, port, path, query, fragment), ) - attrs_dict = {} + attrs_dict: t.Dict[str, t.Optional[str]] = {} for name, value in attributes: if value is misc.UseExisting: value = getattr(self, name) @@ -218,7 +245,7 @@ def copy_with( ) return ParseResult(uri_ref=ref, encoding=self.encoding, **attrs_dict) - def encode(self, encoding=None): + def encode(self, encoding: t.Optional[str] = None) -> "ParseResultBytes": """Convert to an instance of ParseResultBytes.""" encoding = encoding or self.encoding attrs = dict( @@ -234,7 +261,7 @@ def encode(self, encoding=None): uri_ref=self.reference, encoding=encoding, **attrs ) - def unsplit(self, use_idna=False): + def unsplit(self, use_idna: bool = False) -> str: """Create a URI string from the components. :returns: The parsed URI reconstituted as a string. @@ -249,23 +276,34 @@ def unsplit(self, use_idna=False): class ParseResultBytes( - namedtuple("ParseResultBytes", PARSED_COMPONENTS), ParseResultMixin + namedtuple("ParseResultBytes", PARSED_COMPONENTS), ParseResultMixin[bytes] ): """Compatibility shim for the urlparse.ParseResultBytes object.""" + scheme: t.Optional[bytes] + userinfo: t.Optional[bytes] + host: t.Optional[bytes] + port: t.Optional[int] + path: t.Optional[bytes] + query: t.Optional[bytes] + fragment: t.Optional[bytes] + encoding: str + reference: "uri.URIReference" + lazy_normalize: bool + def __new__( cls, - scheme, - userinfo, - host, - port, - path, - query, - fragment, - uri_ref, - encoding="utf-8", - lazy_normalize=True, - ): + scheme: t.Optional[bytes], + userinfo: t.Optional[bytes], + host: t.Optional[bytes], + port: t.Optional[int], + path: t.Optional[bytes], + query: t.Optional[bytes], + fragment: t.Optional[bytes], + uri_ref: "uri.URIReference", + encoding: str = "utf-8", + lazy_normalize: bool = True, + ) -> _Self: """Create a new ParseResultBytes instance.""" parse_result = super().__new__( cls, @@ -285,16 +323,16 @@ def __new__( @classmethod def from_parts( cls, - scheme=None, - userinfo=None, - host=None, - port=None, - path=None, - query=None, - fragment=None, - encoding="utf-8", - lazy_normalize=True, - ): + scheme: t.Optional[str] = None, + userinfo: t.Optional[str] = None, + host: t.Optional[str] = None, + port: t.Optional[t.Union[int, str]] = None, + path: t.Optional[str] = None, + query: t.Optional[str] = None, + fragment: t.Optional[str] = None, + encoding: str = "utf-8", + lazy_normalize: bool = True, + ) -> _Self: """Create a ParseResult instance from its parts.""" authority = "" if userinfo is not None: @@ -330,8 +368,12 @@ def from_parts( @classmethod def from_string( - cls, uri_string, encoding="utf-8", strict=True, lazy_normalize=True - ): + cls, + uri_string: t.Union[str, bytes], + encoding: str = "utf-8", + strict: bool = True, + lazy_normalize: bool = True, + ) -> _Self: """Parse a URI from the given unicode URI string. :param str uri_string: Unicode URI to be parsed into a reference. @@ -361,21 +403,21 @@ def from_string( ) @property - def authority(self): + def authority(self) -> bytes: """Return the normalized authority.""" return self.reference.authority.encode(self.encoding) def copy_with( self, - scheme=misc.UseExisting, - userinfo=misc.UseExisting, - host=misc.UseExisting, - port=misc.UseExisting, - path=misc.UseExisting, - query=misc.UseExisting, - fragment=misc.UseExisting, - lazy_normalize=True, - ): + scheme: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + userinfo: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + host: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + port: t.Optional[t.Union[int, str, bytes]] = misc.UseExisting, + path: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + query: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + fragment: t.Optional[t.Union[str, bytes]] = misc.UseExisting, + lazy_normalize: bool = True, + ) -> "ParseResultBytes": """Create a copy of this instance replacing with specified parts.""" attributes = zip( PARSED_COMPONENTS, @@ -388,6 +430,10 @@ def copy_with( if not isinstance(value, bytes) and hasattr(value, "encode"): value = value.encode(self.encoding) attrs_dict[name] = value + + if t.TYPE_CHECKING: + attrs_dict = t.cast(t.Dict[str, t.Optional[bytes]], attrs_dict) + authority = self._generate_authority(attrs_dict) to_str = compat.to_str ref = self.reference.copy_with( @@ -406,7 +452,7 @@ def copy_with( **attrs_dict, ) - def unsplit(self, use_idna=False): + def unsplit(self, use_idna: bool = False) -> bytes: """Create a URI bytes object from the components. :returns: The parsed URI reconstituted as a string. @@ -425,7 +471,9 @@ def unsplit(self, use_idna=False): return uri.encode(self.encoding) -def split_authority(authority): +def split_authority( + authority: str, +) -> t.Tuple[t.Optional[str], t.Optional[str], t.Optional[str]]: # Initialize our expected return values userinfo = host = port = None # Initialize an extra var we may need to use @@ -452,7 +500,10 @@ def split_authority(authority): return userinfo, host, port -def authority_from(reference, strict): +def authority_from( + reference: "uri.URIReference", + strict: bool, +) -> t.Tuple[t.Optional[str], t.Optional[str], t.Optional[int]]: try: subauthority = reference.authority_info() except exceptions.InvalidAuthority: @@ -462,9 +513,9 @@ def authority_from(reference, strict): else: # Thanks to Richard Barrell for this idea: # https://twitter.com/0x2ba22e11/status/617338811975139328 - userinfo, host, port = ( - subauthority.get(p) for p in ("userinfo", "host", "port") - ) + userinfo = subauthority.get("userinfo") + host = subauthority.get("host") + port = subauthority.get("port") if port: if port.isascii() and port.isdigit(): diff --git a/src/rfc3986/py.typed b/src/rfc3986/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/rfc3986/uri.py b/src/rfc3986/uri.py index 9fff75e..e382498 100644 --- a/src/rfc3986/uri.py +++ b/src/rfc3986/uri.py @@ -1,4 +1,5 @@ """Module containing the implementation of the URIReference class.""" + # Copyright (c) 2014 Rackspace # Copyright (c) 2015 Ian Stapleton Cordasco # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,15 +14,16 @@ # implied. # See the License for the specific language governing permissions and # limitations under the License. -from collections import namedtuple +import typing as t from . import compat from . import misc from . import normalizers from ._mixin import URIMixin +from ._typing_compat import Self as _Self -class URIReference(namedtuple("URIReference", misc.URI_COMPONENTS), URIMixin): +class URIReference(misc.URIReferenceBase, URIMixin): """Immutable object representing a parsed URI Reference. .. note:: @@ -79,11 +81,17 @@ class URIReference(namedtuple("URIReference", misc.URI_COMPONENTS), URIMixin): The port parsed from the authority. """ - slots = () + encoding: str def __new__( - cls, scheme, authority, path, query, fragment, encoding="utf-8" - ): + cls, + scheme: t.Optional[str], + authority: t.Optional[str], + path: t.Optional[str], + query: t.Optional[str], + fragment: t.Optional[str], + encoding: str = "utf-8", + ) -> _Self: """Create a new URIReference.""" ref = super().__new__( cls, @@ -98,18 +106,18 @@ def __new__( __hash__ = tuple.__hash__ - def __eq__(self, other): + def __eq__(self, other: object) -> bool: """Compare this reference to another.""" other_ref = other if isinstance(other, tuple): - other_ref = URIReference(*other) + other_ref = type(self)(*other) elif not isinstance(other, URIReference): try: - other_ref = URIReference.from_string(other) + other_ref = self.from_string(other) except TypeError: raise TypeError( - "Unable to compare URIReference() to {}()".format( - type(other).__name__ + "Unable to compare {}() to {}()".format( + type(self).__name__, type(other).__name__ ) ) @@ -117,7 +125,7 @@ def __eq__(self, other): naive_equality = tuple(self) == tuple(other_ref) return naive_equality or self.normalized_equality(other_ref) - def normalize(self): + def normalize(self) -> "URIReference": """Normalize this reference as described in Section 6.2.2. This is not an in-place normalization. Instead this creates a new @@ -140,7 +148,11 @@ def normalize(self): ) @classmethod - def from_string(cls, uri_string, encoding="utf-8"): + def from_string( + cls, + uri_string: t.Union[str, bytes], + encoding: str = "utf-8", + ) -> _Self: """Parse a URI reference from the given unicode URI string. :param str uri_string: Unicode URI to be parsed into a reference. diff --git a/src/rfc3986/validators.py b/src/rfc3986/validators.py index 21e6eb9..0727586 100644 --- a/src/rfc3986/validators.py +++ b/src/rfc3986/validators.py @@ -12,9 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """Module containing the validation logic for rfc3986.""" +import typing as t + from . import exceptions from . import misc from . import normalizers +from . import uri +from ._typing_compat import Self as _Self class Validator: @@ -48,13 +52,13 @@ class Validator: ["scheme", "userinfo", "host", "port", "path", "query", "fragment"] ) - def __init__(self): + def __init__(self) -> None: """Initialize our default validations.""" - self.allowed_schemes = set() - self.allowed_hosts = set() - self.allowed_ports = set() - self.allow_password = True - self.required_components = { + self.allowed_schemes: t.Set[str] = set() + self.allowed_hosts: t.Set[str] = set() + self.allowed_ports: t.Set[str] = set() + self.allow_password: bool = True + self.required_components: t.Dict[str, bool] = { "scheme": False, "userinfo": False, "host": False, @@ -63,9 +67,11 @@ def __init__(self): "query": False, "fragment": False, } - self.validated_components = self.required_components.copy() + self.validated_components: t.Dict[str, bool] = ( + self.required_components.copy() + ) - def allow_schemes(self, *schemes): + def allow_schemes(self, *schemes: str) -> _Self: """Require the scheme to be one of the provided schemes. .. versionadded:: 1.0 @@ -81,7 +87,7 @@ def allow_schemes(self, *schemes): self.allowed_schemes.add(normalizers.normalize_scheme(scheme)) return self - def allow_hosts(self, *hosts): + def allow_hosts(self, *hosts: str) -> _Self: """Require the host to be one of the provided hosts. .. versionadded:: 1.0 @@ -97,7 +103,7 @@ def allow_hosts(self, *hosts): self.allowed_hosts.add(normalizers.normalize_host(host)) return self - def allow_ports(self, *ports): + def allow_ports(self, *ports: str) -> _Self: """Require the port to be one of the provided ports. .. versionadded:: 1.0 @@ -115,7 +121,7 @@ def allow_ports(self, *ports): self.allowed_ports.add(port) return self - def allow_use_of_password(self): + def allow_use_of_password(self) -> _Self: """Allow passwords to be present in the URI. .. versionadded:: 1.0 @@ -128,7 +134,7 @@ def allow_use_of_password(self): self.allow_password = True return self - def forbid_use_of_password(self): + def forbid_use_of_password(self) -> _Self: """Prevent passwords from being included in the URI. .. versionadded:: 1.0 @@ -141,7 +147,7 @@ def forbid_use_of_password(self): self.allow_password = False return self - def check_validity_of(self, *components): + def check_validity_of(self, *components: str) -> _Self: """Check the validity of the components provided. This can be specified repeatedly. @@ -155,7 +161,7 @@ def check_validity_of(self, *components): :rtype: Validator """ - components = [c.lower() for c in components] + components = tuple(c.lower() for c in components) for component in components: if component not in self.COMPONENT_NAMES: raise ValueError(f'"{component}" is not a valid component') @@ -164,7 +170,7 @@ def check_validity_of(self, *components): ) return self - def require_presence_of(self, *components): + def require_presence_of(self, *components: str) -> _Self: """Require the components provided. This can be specified repeatedly. @@ -178,7 +184,7 @@ def require_presence_of(self, *components): :rtype: Validator """ - components = [c.lower() for c in components] + components = tuple(c.lower() for c in components) for component in components: if component not in self.COMPONENT_NAMES: raise ValueError(f'"{component}" is not a valid component') @@ -187,7 +193,7 @@ def require_presence_of(self, *components): ) return self - def validate(self, uri): + def validate(self, uri: "uri.URIReference") -> None: """Check a URI for conditions specified on this validator. .. versionadded:: 1.0 @@ -229,7 +235,7 @@ def validate(self, uri): ensure_one_of(self.allowed_ports, uri, "port") -def check_password(uri): +def check_password(uri: "uri.URIReference") -> None: """Assert that there is no password present in the uri.""" userinfo = uri.userinfo if not userinfo: @@ -240,7 +246,11 @@ def check_password(uri): raise exceptions.PasswordForbidden(uri) -def ensure_one_of(allowed_values, uri, attribute): +def ensure_one_of( + allowed_values: t.Collection[object], + uri: "uri.URIReference", + attribute: str, +) -> None: """Assert that the uri's attribute is one of the allowed values.""" value = getattr(uri, attribute) if value is not None and allowed_values and value not in allowed_values: @@ -251,7 +261,10 @@ def ensure_one_of(allowed_values, uri, attribute): ) -def ensure_required_components_exist(uri, required_components): +def ensure_required_components_exist( + uri: "uri.URIReference", + required_components: t.Iterable[str], +) -> None: """Assert that all required components are present in the URI.""" missing_components = sorted( component @@ -262,7 +275,11 @@ def ensure_required_components_exist(uri, required_components): raise exceptions.MissingComponentError(uri, *missing_components) -def is_valid(value, matcher, require): +def is_valid( + value: t.Optional[str], + matcher: t.Pattern[str], + require: bool, +) -> bool: """Determine if a value is valid based on the provided matcher. :param str value: @@ -273,13 +290,17 @@ def is_valid(value, matcher, require): Whether or not the value is required. """ if require: - return value is not None and matcher.match(value) + return value is not None and bool(matcher.match(value)) # require is False and value is not None - return value is None or matcher.match(value) + return value is None or bool(matcher.match(value)) -def authority_is_valid(authority, host=None, require=False): +def authority_is_valid( + authority: t.Optional[str], + host: t.Optional[str] = None, + require: bool = False, +) -> bool: """Determine if the authority string is valid. :param str authority: @@ -299,7 +320,7 @@ def authority_is_valid(authority, host=None, require=False): return validated -def host_is_valid(host, require=False): +def host_is_valid(host: t.Optional[str], require: bool = False) -> bool: """Determine if the host string is valid. :param str host: @@ -319,7 +340,7 @@ def host_is_valid(host, require=False): return validated -def scheme_is_valid(scheme, require=False): +def scheme_is_valid(scheme: t.Optional[str], require: bool = False) -> bool: """Determine if the scheme is valid. :param str scheme: @@ -334,7 +355,7 @@ def scheme_is_valid(scheme, require=False): return is_valid(scheme, misc.SCHEME_MATCHER, require) -def path_is_valid(path, require=False): +def path_is_valid(path: t.Optional[str], require: bool = False) -> bool: """Determine if the path component is valid. :param str path: @@ -349,7 +370,7 @@ def path_is_valid(path, require=False): return is_valid(path, misc.PATH_MATCHER, require) -def query_is_valid(query, require=False): +def query_is_valid(query: t.Optional[str], require: bool = False) -> bool: """Determine if the query component is valid. :param str query: @@ -364,7 +385,10 @@ def query_is_valid(query, require=False): return is_valid(query, misc.QUERY_MATCHER, require) -def fragment_is_valid(fragment, require=False): +def fragment_is_valid( + fragment: t.Optional[str], + require: bool = False, +) -> bool: """Determine if the fragment component is valid. :param str fragment: @@ -379,7 +403,7 @@ def fragment_is_valid(fragment, require=False): return is_valid(fragment, misc.FRAGMENT_MATCHER, require) -def valid_ipv4_host_address(host): +def valid_ipv4_host_address(host: str) -> bool: """Determine if the given host is a valid IPv4 address.""" # If the host exists, and it might be IPv4, check each byte in the # address. @@ -396,7 +420,10 @@ def valid_ipv4_host_address(host): _SUBAUTHORITY_VALIDATORS = {"userinfo", "host", "port"} -def subauthority_component_is_valid(uri, component): +def subauthority_component_is_valid( + uri: "uri.URIReference", + component: str, +) -> bool: """Determine if the userinfo, host, and port are valid.""" try: subauthority_dict = uri.authority_info() @@ -410,19 +437,28 @@ def subauthority_component_is_valid(uri, component): elif component != "port": return True - try: - port = int(subauthority_dict["port"]) - except TypeError: - # If the port wasn't provided it'll be None and int(None) raises a - # TypeError + port = subauthority_dict["port"] + + if port is None: return True - return 0 <= port <= 65535 + # We know it has to have fewer than 6 digits if it exists. + if not (port.isdigit() and len(port) < 6): # pragma: no cover + # This branch can only execute when this function is called directly + # with a URI reference manually constructed with an invalid port. + # Such a use case is unsupported, since this function isn't part of + # the public API. + return False + + return 0 <= int(port) <= 65535 -def ensure_components_are_valid(uri, validated_components): +def ensure_components_are_valid( + uri: "uri.URIReference", + validated_components: t.List[str], +) -> None: """Assert that all components are valid in the URI.""" - invalid_components = set() + invalid_components: set[str] = set() for component in validated_components: if component in _SUBAUTHORITY_VALIDATORS: if not subauthority_component_is_valid(uri, component): diff --git a/tests/test_api.py b/tests/test_api.py index 3b310ed..dacae72 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,7 +1,7 @@ +from rfc3986.api import URIReference from rfc3986.api import is_valid_uri from rfc3986.api import normalize_uri from rfc3986.api import uri_reference -from rfc3986.api import URIReference def test_uri_reference(): diff --git a/tests/test_builder.py b/tests/test_builder.py index 338b3a2..fb6ec3c 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -19,7 +19,8 @@ import pytest -from rfc3986 import builder, uri_reference +from rfc3986 import builder +from rfc3986 import uri_reference def test_builder_default(): diff --git a/tests/test_parseresult.py b/tests/test_parseresult.py index 3e94138..5788448 100644 --- a/tests/test_parseresult.py +++ b/tests/test_parseresult.py @@ -14,10 +14,11 @@ import pytest import rfc3986 -from . import base from rfc3986 import exceptions from rfc3986 import parseresult as pr +from . import base + INVALID_PORTS = [ "443:80", "443:80:443", diff --git a/tests/test_unicode_support.py b/tests/test_unicode_support.py index 6a35244..8c388fb 100644 --- a/tests/test_unicode_support.py +++ b/tests/test_unicode_support.py @@ -5,7 +5,6 @@ from rfc3986 import uri_reference from rfc3986 import urlparse - SNOWMAN = b"\xe2\x98\x83" SNOWMAN_PARAMS = b"http://example.com?utf8=" + SNOWMAN SNOWMAN_HOST = b"http://" + SNOWMAN + b".com" diff --git a/tests/test_uri.py b/tests/test_uri.py index 1c5b0ff..06ac2a8 100644 --- a/tests/test_uri.py +++ b/tests/test_uri.py @@ -1,11 +1,12 @@ import pytest -from . import base from rfc3986.exceptions import InvalidAuthority from rfc3986.exceptions import ResolutionError from rfc3986.misc import URI_MATCHER from rfc3986.uri import URIReference +from . import base + @pytest.fixture def scheme_and_path_uri(): diff --git a/tests/test_validators.py b/tests/test_validators.py index 0ec7449..2bab3a1 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,4 +1,5 @@ """Tests for the validators module.""" + import pytest import rfc3986 diff --git a/tests/verify_types.py b/tests/verify_types.py new file mode 100644 index 0000000..b0b2350 --- /dev/null +++ b/tests/verify_types.py @@ -0,0 +1,77 @@ +"""This script is a shim around `pyright --verifytypes` to determine if the +current typing coverage meets the expected coverage. The previous command by +itself won't suffice, since its expected coverage can't be modified from 100%. +Useful while still adding annotations to the library. +""" + +import argparse +import json +import subprocess +from decimal import Decimal + +PYRIGHT_CMD = ("pyright", "--verifytypes", "rfc3986", "--outputjson") + + +def validate_coverage(inp: str) -> Decimal: + """Ensure the given coverage score is between 0 and 100 (inclusive).""" + + coverage = Decimal(inp) + if not (0 <= coverage <= 100): + raise ValueError + return coverage + + +def main() -> int: + """Determine if rfc3986's typing coverage meets our expected coverage.""" + + parser = argparse.ArgumentParser() + parser.add_argument( + "--fail-under", + default=Decimal("75"), + type=validate_coverage, + help="The target typing coverage to not fall below (default: 75).", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Whether to hide the full output from `pyright --verifytypes`.", + ) + + args = parser.parse_args() + + expected_coverage: Decimal = args.fail_under / 100 + quiet: bool = args.quiet + + try: + output = subprocess.check_output( + PYRIGHT_CMD, + stderr=subprocess.STDOUT, + text=True, + ) + except subprocess.CalledProcessError as exc: + output = exc.output + + verifytypes_output = json.loads(output) + raw_score = verifytypes_output["typeCompleteness"]["completenessScore"] + actual_coverage = Decimal(raw_score) + + if not quiet: + # Switch back to normal output instead of json, for readability. + subprocess.run(PYRIGHT_CMD[:-1]) + + if actual_coverage >= expected_coverage: + print( + f"OK - Required typing coverage of {expected_coverage:.2%} " + f"reached. Total typing coverage: {actual_coverage:.2%}." + ) + return 0 + else: + print( + f"FAIL - Required typing coverage of {expected_coverage:.2%} not " + f"reached. Total typing coverage: {actual_coverage:.2%}." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index 688c016..bd4b757 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{37,38,39,310,311,312},lint +envlist = py{37,38,39,310,311,312},lint,typing [testenv] pip_pre = False @@ -25,9 +25,11 @@ basepython = python3 skip_install = true deps = {[testenv:flake8]deps} + isort black commands = - black -l 79 {env:BLACK_ARGS:} -t py37 src/rfc3986 tests/ + isort src/rfc3986 tests/ + black {env:BLACK_ARGS:} src/rfc3986 tests/ {[testenv:flake8]commands} [testenv:flake8] @@ -36,9 +38,13 @@ skip_install = true deps = flake8 flake8-docstrings - flake8-import-order commands = flake8 {posargs} src/rfc3986 +[testenv:typing] +deps = + pyright +commands = python tests/verify_types.py + [testenv:venv] commands = {posargs} @@ -87,4 +93,3 @@ exclude = .cache, .eggs max-complexity = 10 -import-order-style = google