diff --git a/httoop/codecs/__init__.py b/httoop/codecs/__init__.py index e08ff02f..1ddd3e35 100644 --- a/httoop/codecs/__init__.py +++ b/httoop/codecs/__init__.py @@ -2,6 +2,7 @@ Module containing various codecs which are common used in combination with HTTP. """ + from __future__ import annotations import inspect @@ -14,11 +15,7 @@ CODECS = {} types = (application, audio, example, image, message, model, multipart, text, video) -__all__ = [ - 'CODECS', 'Codec', - 'application', 'audio', 'example', 'image', - 'message', 'model', 'multipart', 'text', 'video' -] +__all__ = ['CODECS', 'Codec', 'application', 'audio', 'example', 'image', 'message', 'model', 'multipart', 'text', 'video'] def lookup(encoding: str, raise_errors: bool = True) -> Any: diff --git a/httoop/date.py b/httoop/date.py index 1402b870..0cd74f94 100644 --- a/httoop/date.py +++ b/httoop/date.py @@ -15,14 +15,14 @@ from typing import Any from httoop.exceptions import InvalidDate -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import _ __all__ = ['Date'] -class Date(metaclass=HTTPSemantic): +class Date(Semantic): """ A HTTP Date string. diff --git a/httoop/header/__init__.py b/httoop/header/__init__.py index f0684979..5b706e3a 100644 --- a/httoop/header/__init__.py +++ b/httoop/header/__init__.py @@ -8,31 +8,16 @@ .. seealso:: :rfc:`2616#section-14` """ -# FIXME: python3? # TODO: add a MAXIMUM of 500 headers? -import inspect - -from httoop.header import ( - auth, - cache, - conditional, - messaging, - range, # pylint: disable=W0622 - security, - semantics, -) -from httoop.header.element import HEADER, HeaderElement, HeaderType +from httoop.header import auth, cache, conditional, messaging, ranges, security +from httoop.header.element import HEADER, HeaderElement from httoop.header.headers import Headers from httoop.header.messaging import Server, UserAgent -__all__ = ['Headers', 'Server', 'UserAgent'] - -types = (semantics, messaging, conditional, range, cache, auth, security) - -for _, member in (member for type_ in types for member in inspect.getmembers(type_, inspect.isclass)): - if isinstance(member, HeaderType) and member is not HeaderElement and not _.startswith('_'): - HEADER[member.__name__] = member - globals()[_] = member - __all__.append(_) +__all__ = ['Headers', 'Server', 'UserAgent', 'auth', 'cache', 'conditional', 'messaging', 'ranges', 'security', 'HeaderElement'] +for member in HEADER.values(): + name = member.__name__ + __all__.append(name) + globals()[name] = member diff --git a/httoop/header/auth.py b/httoop/header/auth.py index af335c72..852217b3 100644 --- a/httoop/header/auth.py +++ b/httoop/header/auth.py @@ -6,28 +6,28 @@ class Authorization(AuthRequestElement): is_request_header = True -class ProxyAuthenticate(_ListElement, _HopByHopElement, AuthResponseElement): - __name__ = 'Proxy-Authenticate' +class ProxyAuthenticate(_ListElement, _HopByHopElement, AuthResponseElement, name='Proxy-Authenticate'): + is_response_header = True -class ProxyAuthorization(_HopByHopElement, AuthRequestElement): - __name__ = 'Proxy-Authorization' +class ProxyAuthorization(_HopByHopElement, AuthRequestElement, name='Proxy-Authorization'): + is_request_header = True -class WWWAuthenticate(_ListElement, AuthResponseElement): - __name__ = 'WWW-Authenticate' +class WWWAuthenticate(_ListElement, AuthResponseElement, name='WWW-Authenticate'): + is_response_header = True -class AuthenticationInfo(AuthInfoElement): - __name__ = 'Authentication-Info' +class AuthenticationInfo(AuthInfoElement, name='Authentication-Info'): + is_response_header = True -class ProxyAuthenticationInfo(_HopByHopElement, AuthInfoElement): - __name__ = 'Proxy-Authentication-Info' +class ProxyAuthenticationInfo(_HopByHopElement, AuthInfoElement, name='Proxy-Authentication-Info'): + is_response_header = True diff --git a/httoop/header/cache.py b/httoop/header/cache.py index 5ba40b5f..92550f08 100644 --- a/httoop/header/cache.py +++ b/httoop/header/cache.py @@ -6,8 +6,7 @@ class Age(HeaderElement): is_response_header = True -class CacheControl(HeaderElement): - __name__ = 'Cache-Control' +class CacheControl(HeaderElement, name='Cache-Control'): is_request_header = True is_response_header = True diff --git a/httoop/header/conditional.py b/httoop/header/conditional.py index 6501ad0a..8a5ea94f 100644 --- a/httoop/header/conditional.py +++ b/httoop/header/conditional.py @@ -61,26 +61,26 @@ def __eq__(self, other: object) -> bool: return other.value in {self.value, '*'} -class LastModified(_DateComparable, HeaderElement): - __name__ = 'Last-Modified' +class LastModified(_DateComparable, HeaderElement, name='Last-Modified'): + is_response_header = True -class IfMatch(_MatchElement, HeaderElement): - __name__ = 'If-Match' +class IfMatch(_MatchElement, HeaderElement, name='If-Match'): + is_request_header = True -class IfModifiedSince(_DateComparable, HeaderElement): - __name__ = 'If-Modified-Since' +class IfModifiedSince(_DateComparable, HeaderElement, name='If-Modified-Since'): + is_request_header = True -class IfNoneMatch(_MatchElement, HeaderElement): - __name__ = 'If-None-Match' +class IfNoneMatch(_MatchElement, HeaderElement, name='If-None-Match'): + is_request_header = True -class IfUnmodifiedSince(_DateComparable, HeaderElement): - __name__ = 'If-Unmodified-Since' +class IfUnmodifiedSince(_DateComparable, HeaderElement, name='If-Unmodified-Since'): + is_request_header = True diff --git a/httoop/header/element.py b/httoop/header/element.py index 831326d7..c0580cee 100644 --- a/httoop/header/element.py +++ b/httoop/header/element.py @@ -26,17 +26,10 @@ HEADER = CaseInsensitiveDict() -class HeaderType(type): - - def __new__(cls: type, name: str, bases: Any, dict_: dict[str, Any]) -> Any: - __all__.append(name) - name = dict_.get('__name__', name) - return super().__new__(cls, name, bases, dict_) - - -class HeaderElement(metaclass=HeaderType): +class HeaderElement: """An element (with parameters) from an HTTP header's element list.""" + name = '' priority = None is_request_header = False is_response_header = False @@ -55,6 +48,13 @@ def __init__(self, value: str, params: Any | None = None) -> None: self.params = ByteUnicodeDict(params or {}) self.sanitize() + def __init_subclass__(cls, name=None, **kwargs): + super().__init_subclass__(**kwargs) + name = name or cls.__name__ + if name != 'HeaderElement' and not name.startswith('_'): + HEADER[name] = cls + cls.name = name + def sanitize(self) -> None: pass @@ -101,7 +101,7 @@ def parseparam(cls, atom: bytes) -> tuple[bytes, bytes, bool]: try: val, quoted = cls.unescape_param(val.strip()) except InvalidHeader: - raise InvalidHeader(_('Unquoted parameter %r in %r containing TSPECIALS: %r'), key, cls.__name__, val) + raise InvalidHeader(_('Unquoted parameter %r in %r containing TSPECIALS: %r'), key, cls.name, val) return cls.unescape_key(key), val, quoted @classmethod @@ -114,7 +114,7 @@ def unescape_param(cls, value: bytes) -> tuple[bytes, bool]: if quoted: value = re.sub(b'\\\\(?!\\\\)', b'', value[1:-1]) elif cls.RE_TSPECIALS.search(value): - raise InvalidHeader(_('Unquoted parameter in %r containing TSPECIALS: %r'), cls.__name__, value) + raise InvalidHeader(_('Unquoted parameter in %r containing TSPECIALS: %r'), cls.name, value) return value, quoted @classmethod @@ -248,7 +248,7 @@ def encode_rfc2047(cls, value: str) -> bytes: def __repr__(self) -> str: params = f', {self.params!r}' if self.params else '' - return f'<{self.__class__.__name__}({self.value!r}{params})>' + return f'<{self.name}({self.value!r}{params})>' class MimeType: diff --git a/httoop/header/headers.py b/httoop/header/headers.py index bcb7a92b..7c2e38df 100644 --- a/httoop/header/headers.py +++ b/httoop/header/headers.py @@ -5,11 +5,11 @@ from httoop.exceptions import InvalidHeader from httoop.header.element import HEADER, HeaderElement -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import CaseInsensitiveDict, _, to_unicode -class Headers(CaseInsensitiveDict, metaclass=HTTPSemantic): +class Headers(CaseInsensitiveDict, Semantic): __slots__ = () @@ -45,7 +45,7 @@ def formatkey(cls, key: bytes | str) -> str: if cls.HEADER_RE.search(key.encode('utf-8')): raise InvalidHeader(_('Invalid header name: %r'), key) try: - return to_unicode(HEADER[key].__name__) + return to_unicode(HEADER[key].name) except KeyError: return key @@ -156,7 +156,7 @@ def __encoded_items(self): for key, values in self.items(): Element = HEADER.get(key, HeaderElement) if Element is not HeaderElement: - key = Element.__name__ + key = Element.name key = key.encode('ascii', 'ignore') if Element.list_element: for value in Element.split(values): diff --git a/httoop/header/messaging.py b/httoop/header/messaging.py index 622115de..91b789fe 100644 --- a/httoop/header/messaging.py +++ b/httoop/header/messaging.py @@ -19,7 +19,7 @@ class CodecElement: def sanitize(self) -> None: super().sanitize() if self.value and self.codec is None and self.raise_on_missing_codec: - raise InvalidHeader(_('Unknown %s: %r'), self.__name__, self.value) + raise InvalidHeader(_('Unknown %s: %r'), self.name, self.value) @property def codec(self) -> Any: @@ -50,28 +50,23 @@ def sanitize(self) -> None: self.value = '*/*' -class AcceptCharset(_AcceptElement): - __name__ = 'Accept-Charset' +class AcceptCharset(_AcceptElement, name='Accept-Charset'): is_request_header = True -class AcceptEncoding(_AcceptElement): - __name__ = 'Accept-Encoding' +class AcceptEncoding(_AcceptElement, name='Accept-Encoding'): is_request_header = True -class AcceptLanguage(_AcceptElement): - __name__ = 'Accept-Language' +class AcceptLanguage(_AcceptElement, name='Accept-Language'): is_request_header = True -class AcceptRanges(_AcceptElement): - __name__ = 'Accept-Ranges' +class AcceptRanges(_AcceptElement, name='Accept-Ranges'): is_request_header = True -class AcceptPatch(_AcceptElement): - __name__ = 'Accept-Patch' +class AcceptPatch(_AcceptElement, name='Accept-Patch'): is_response_header = True @@ -94,9 +89,8 @@ def upgrade(self) -> bool: return self.value.lower() == 'upgrade' -class ContentDisposition(HeaderElement): +class ContentDisposition(HeaderElement, name='Content-Disposition'): - __name__ = 'Content-Disposition' is_response_header = True from httoop.date import Date @@ -144,8 +138,7 @@ def sanitize(self) -> None: raise InvalidHeader(_('Unknown Content-Disposition: %r'), self.value) -class ContentEncoding(CodecElement, HeaderElement): - __name__ = 'Content-Encoding' +class ContentEncoding(CodecElement, HeaderElement, name='Content-Encoding'): is_request_header = True is_response_header = True @@ -162,32 +155,29 @@ class ContentEncoding(CodecElement, HeaderElement): } -class ContentLanguage(HeaderElement): - __name__ = 'Content-Language' +class ContentLanguage(HeaderElement, name='Content-Language'): is_request_header = True is_response_header = True -class ContentLength(HeaderElement): - __name__ = 'Content-Length' +class ContentLength(HeaderElement, name='Content-Length'): is_request_header = True is_response_header = True -class ContentLocation(HeaderElement): - __name__ = 'Content-Location' +class ContentLocation(HeaderElement, name='Content-Location'): + is_response_header = True -class ContentMD5(HeaderElement): - __name__ = 'Content-MD5' +class ContentMD5(HeaderElement, name='Content-MD5'): + is_request_header = True is_response_header = True -class ContentType(HeaderElement, MimeType, CodecElement): +class ContentType(HeaderElement, MimeType, CodecElement, name='Content-Type'): - __name__ = 'Content-Type' is_request_header = True is_response_header = True @@ -344,8 +334,8 @@ def sanitize(self) -> None: raise InvalidHeader(_('Invalid Host header: %s'), self.value) -class XForwardedHost(Host): - __name__ = 'X-Forwarded-Host' +class XForwardedHost(Host, name='X-Forwarded-Host'): + is_request_header = True @@ -353,9 +343,8 @@ class Location(HeaderElement): is_response_header = True -class MaxForwards(HeaderElement): +class MaxForwards(HeaderElement, name='Max-Forwards'): - __name__ = 'Max-Forwards' is_response_header = True @@ -363,9 +352,8 @@ class Referer(HeaderElement): is_request_header = True -class RetryAfter(HeaderElement): +class RetryAfter(HeaderElement, name='Retry-After'): - __name__ = 'Retry-After' is_response_header = True @@ -375,9 +363,8 @@ class Server(HeaderElement): is_response_header = True -class SetCookie(_ListElement, _CookieElement): +class SetCookie(_ListElement, _CookieElement, name='Set-Cookie'): - __name__ = 'Set-Cookie' is_response_header = True from httoop.date import Date @@ -443,8 +430,7 @@ def sanitize(self) -> None: raise InvalidHeader(_('A Trailer header MUST NOT contain %r field'), self.value.title()) -class TransferEncoding(_HopByHopElement, CodecElement, HeaderElement): - __name__ = 'Transfer-Encoding' +class TransferEncoding(_HopByHopElement, CodecElement, HeaderElement, name='Transfer-Encoding'): is_response_header = True is_request_header = True @@ -468,8 +454,8 @@ def websocket(self) -> bool: return self.value.lower() == 'websocket' -class UserAgent(HeaderElement): - __name__ = 'User-Agent' +class UserAgent(HeaderElement, name='User-Agent'): + is_response_header = True is_request_header = True @@ -479,7 +465,6 @@ class Via(HeaderElement): is_response_header = True -class HTTP2Settings(HeaderElement): - __name__ = 'HTTP2-Settings' +class HTTP2Settings(HeaderElement, name='HTTP2-Settings'): is_request_header = True is_response_header = True diff --git a/httoop/header/range.py b/httoop/header/ranges.py similarity index 97% rename from httoop/header/range.py rename to httoop/header/ranges.py index 843182bc..f5cd4362 100644 --- a/httoop/header/range.py +++ b/httoop/header/ranges.py @@ -16,9 +16,8 @@ __all__ = ('ContentRange', 'IfRange', 'Range') -class ContentRange(HeaderElement): +class ContentRange(HeaderElement, name='Content-Range'): - __name__ = 'Content-Range' is_response_header = True def __init__(self, value: str, range_: tuple[int, None] | tuple[int, int] | None, length: int | str | None) -> None: @@ -62,8 +61,7 @@ def parse(cls, elementstr: bytes) -> ContentRange: return cls(value.decode('ISO8859-1'), (start, end), complete_length) -class IfRange(HeaderElement): - __name__ = 'If-Range' +class IfRange(HeaderElement, name='If-Range'): is_request_header = True diff --git a/httoop/header/security.py b/httoop/header/security.py index cf30c912..12460bff 100644 --- a/httoop/header/security.py +++ b/httoop/header/security.py @@ -13,7 +13,7 @@ from httoop.uri.http import HTTPS -class ContentSecurityPolicy(HeaderElement): +class ContentSecurityPolicy(HeaderElement, name='Content-Security-Policy'): """ Content security policy (CSP). @@ -22,7 +22,6 @@ class ContentSecurityPolicy(HeaderElement): ..seealso:: http://www.w3.org/TR/CSP2/ """ - __name__ = 'Content-Security-Policy' is_response_header = True RE_SPLIT = re.compile(rb';') @@ -32,12 +31,12 @@ def compose(self) -> bytes: return b'%s %s; ' % (self.value.encode('ISO8859-1'), b' '.join(self.params.keys())) -class ContentSecurityPolicyReportOnly(ContentSecurityPolicy): - __name__ = 'Content-Security-Policy-Report-Only' +class ContentSecurityPolicyReportOnly(ContentSecurityPolicy, name='Content-Security-Policy-Report-Only'): + is_response_header = True -class StrictTransportSecurity(HeaderElement): +class StrictTransportSecurity(HeaderElement, name='Strict-Transport-Security'): """ HTTP strict transport security (HSTS). @@ -46,7 +45,6 @@ class StrictTransportSecurity(HeaderElement): ..seealso:: :rfc:`rfc6797` """ - __name__ = 'Strict-Transport-Security' is_response_header = True @property @@ -58,14 +56,13 @@ def max_age(self) -> int: return integer(self.value.split('=', 1)[1]) # TODO: more generic parsing -class ContentTypeOptions(HeaderElement): +class ContentTypeOptions(HeaderElement, name='X-Content-Type-Options'): """ Content Type options. "nosniff" forces user agents to strictly evaluate the Content-Type response header. """ - __name__ = 'X-Content-Type-Options' is_response_header = True @property @@ -73,7 +70,7 @@ def nosniff(self) -> bool: return self == 'nosniff' -class FrameOptions(HeaderElement): +class FrameOptions(HeaderElement, name='X-Frame-Options'): """ Frame Options. @@ -83,7 +80,6 @@ class FrameOptions(HeaderElement): ..seealso:: :rfc:`7034` """ - __name__ = 'X-Frame-Options' is_response_header = True RE_PARAMS = re.compile(b'\\s+') @@ -105,26 +101,24 @@ def allow_from(self) -> list[HTTPS]: return None -class PermittedCrossDomainPolicies(HeaderElement): - __name__ = 'X-Permitted-Cross-Domain-Policies' +class PermittedCrossDomainPolicies(HeaderElement, name='X-Permitted-Cross-Domain-Policies'): + is_response_header = True -class PublicKeyPins(HeaderElement): +class PublicKeyPins(HeaderElement, name='Public-Key-Pins'): """Public Key Pinning Extension for HTTP (HPKP).""" - __name__ = 'Public-Key-Pins' is_response_header = True -class XSSProtection(HeaderElement): +class XSSProtection(HeaderElement, name='X-XSS-Protection'): """ Cross site scripting (XSS) protection. Enable cross site scripting filter in the user agent. """ - __name__ = 'X-XSS-Protection' is_response_header = True @property diff --git a/httoop/header/semantics.py b/httoop/header/semantics.py deleted file mode 100644 index e69de29b..00000000 diff --git a/httoop/messages/body.py b/httoop/messages/body.py index 376d53e5..a6d54518 100644 --- a/httoop/messages/body.py +++ b/httoop/messages/body.py @@ -12,14 +12,14 @@ from typing import Any, Iterator from httoop.header import Headers -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import IFile _SENTINEL = object() -class Body(IFile, metaclass=HTTPSemantic): +class Body(IFile, Semantic): """ A HTTP message body. diff --git a/httoop/messages/message.py b/httoop/messages/message.py index de42dff9..c700cc3a 100644 --- a/httoop/messages/message.py +++ b/httoop/messages/message.py @@ -8,13 +8,13 @@ from httoop.header import Headers from httoop.messages.body import Body from httoop.messages.protocol import Protocol -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic __all__ = ('Message',) -class Message(metaclass=HTTPSemantic): +class Message(Semantic): """ A HTTP message. diff --git a/httoop/messages/method.py b/httoop/messages/method.py index c25be41b..a521833e 100644 --- a/httoop/messages/method.py +++ b/httoop/messages/method.py @@ -9,14 +9,14 @@ import re from httoop.exceptions import InvalidLine -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import _ __all__ = ('Method',) -class Method(metaclass=HTTPSemantic): +class Method(Semantic): """A HTTP request method.""" __slots__ = ('__method',) diff --git a/httoop/messages/protocol.py b/httoop/messages/protocol.py index dd7b6d2f..2d1762cb 100644 --- a/httoop/messages/protocol.py +++ b/httoop/messages/protocol.py @@ -9,14 +9,14 @@ import re from httoop.exceptions import InvalidLine -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import _ __all__ = ('Protocol',) -class Protocol(metaclass=HTTPSemantic): +class Protocol(Semantic): """The HTTP protocol version.""" __slots__ = ('__protocol', 'name') diff --git a/httoop/messages/request.py b/httoop/messages/request.py index a8ebc5cd..0effd8ac 100644 --- a/httoop/messages/request.py +++ b/httoop/messages/request.py @@ -82,7 +82,7 @@ def parse(self, line: bytes) -> None: def validate_request_uri(self) -> None: uri = self.uri - if not isinstance(uri, (uri.SCHEMES[b'http'], uri.SCHEMES[b'https'])): + if not isinstance(uri, (uri.SCHEMES['http'], uri.SCHEMES['https'])): raise InvalidURI(_('The request URI scheme must be HTTP based.')) if uri.fragment or uri.username or uri.password: raise InvalidURI(_('The request URI must not contain fragments or user information.')) diff --git a/httoop/meta.py b/httoop/meta.py index e7adbc63..98d72be5 100644 --- a/httoop/meta.py +++ b/httoop/meta.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Any - -__all__ = ['HTTPSemantic'] +__all__ = ['Semantic'] class Semantic: + """Implements the HTTP Semantic interface.""" __slots__ = () @@ -43,15 +42,3 @@ def __format__(self, format_spec) -> str: def __repr__(self) -> str: return f'' - - -class HTTPSemantic(type): - """Implements the HTTP Semantic interface.""" - - def __new__(mcs: type, name: str, bases: Any, dict_: dict[str, Any]) -> Any: - bases = list(bases) - if object in bases: - bases.remove(object) - bases.append(Semantic) - - return super().__new__(mcs, name, tuple(bases), dict_) diff --git a/httoop/semantic/response.py b/httoop/semantic/response.py index 9913885e..00b0c4d5 100644 --- a/httoop/semantic/response.py +++ b/httoop/semantic/response.py @@ -7,7 +7,7 @@ from httoop.exceptions import InvalidHeader from httoop.messages.body import Body from httoop.semantic.message import ComposedMessage -from httoop.status import STATUSES +from httoop.status.status import STATUSES if TYPE_CHECKING: diff --git a/httoop/status/__init__.py b/httoop/status/__init__.py index f9364a81..a15a94a1 100644 --- a/httoop/status/__init__.py +++ b/httoop/status/__init__.py @@ -5,21 +5,17 @@ .. seealso:: :rfc:`2616#section-10` """ -import inspect +from httoop.status.client_error import ClientErrorStatus +from httoop.status.informational import InformationalStatus +from httoop.status.redirect import RedirectStatus +from httoop.status.server_error import ServerErrorStatus +from httoop.status.status import REASONS, STATUSES, Status +from httoop.status.success import SuccessStatus +from httoop.status.types import StatusException -from httoop.status import client_error, informational, redirect, server_error, success -from httoop.status.status import REASONS, Status -from httoop.status.types import StatusException, StatusType +__all__ = ['REASONS', 'ClientErrorStatus', 'InformationalStatus', 'RedirectStatus', 'ServerErrorStatus', 'Status', 'StatusException', 'SuccessStatus'] -__all__ = ['REASONS', 'Status', 'StatusException', 'StatusType'] - -# mapping of status -> Class -STATUSES = {} -types = (informational, success, redirect, client_error, server_error) - -for _, member in (member for type_ in types for member in inspect.getmembers(type_, inspect.isclass)): - if isinstance(member, StatusType) and member is not StatusType: - STATUSES[member.code] = member - globals()[_] = member - __all__.append(_) +for member in STATUSES.values(): + __all__.append(member.__name__) + globals()[member.__name__] = member diff --git a/httoop/status/client_error.py b/httoop/status/client_error.py index f5c5c91a..3ca41ac9 100644 --- a/httoop/status/client_error.py +++ b/httoop/status/client_error.py @@ -11,18 +11,17 @@ class ClientErrorStatus(StatusException): """ -class BAD_REQUEST(ClientErrorStatus): +class BAD_REQUEST(ClientErrorStatus, code=400): """ The generic response code for client side errors. The response entity-body should contain information about what is wrong with the request. """ - code = 400 cacheable = True -class UNAUTHORIZED(ClientErrorStatus): +class UNAUTHORIZED(ClientErrorStatus, code=401): """ The requested resource is protected and no or wrong authentication credentials were given. @@ -32,8 +31,6 @@ class UNAUTHORIZED(ClientErrorStatus): the given credentials and where to register a new account. """ - code = 401 - def __init__(self, authenticate: str, *args, **kwargs) -> None: kwargs.setdefault('headers', {})['WWW-Authenticate'] = authenticate super().__init__(*args, **kwargs) @@ -44,24 +41,20 @@ def to_dict(self) -> dict[str, int | str | dict[str, str]]: return dct -class PAYMENT_REQUIRED(ClientErrorStatus): - - code = 402 +class PAYMENT_REQUIRED(ClientErrorStatus, code=402): + """Payment required.""" -class FORBIDDEN(ClientErrorStatus): +class FORBIDDEN(ClientErrorStatus, code=403): """ The resource can only be served for specific users, at a specific time or from a certain IP address, etc. """ - code = 403 - -class NOT_FOUND(ClientErrorStatus): +class NOT_FOUND(ClientErrorStatus, code=404): """No resource could be found at the given URI.""" - code = 404 cacheable = True def __init__(self, path: str, **kwargs) -> None: @@ -70,14 +63,12 @@ def __init__(self, path: str, **kwargs) -> None: super().__init__(**kwargs) -class METHOD_NOT_ALLOWED(ClientErrorStatus): +class METHOD_NOT_ALLOWED(ClientErrorStatus, code=405): """ The client tried to use a HTTP Method which is not allowed. The Allow-header has to contain the allowed methods for this resource. """ - code = 405 - def __init__(self, allow: str, *args, **kwargs) -> None: kwargs.setdefault('headers', {})['Allow'] = allow super().__init__(*args, **kwargs) @@ -88,7 +79,7 @@ def to_dict(self) -> dict[str, int | str | dict[str, str]]: return dct -class NOT_ACCEPTABLE(ClientErrorStatus): +class NOT_ACCEPTABLE(ClientErrorStatus, code=406): r""" The clients Accept-\*-header wants a representation of the resource which the server can not deliver. @@ -96,28 +87,23 @@ class NOT_ACCEPTABLE(ClientErrorStatus): acceptable representations (similar to 300). """ - code = 406 +class PROXY_AUTHENTICATION_REQUIRED(ClientErrorStatus, code=407): + """Proxy authenticate required.""" -class PROXY_AUTHENTICATION_REQUIRED(ClientErrorStatus): - code = 407 - - -class REQUEST_TIMEOUT(ClientErrorStatus): +class REQUEST_TIMEOUT(ClientErrorStatus, code=408): """ The client opens a connection to a server without sending a request after a specific amount of time. """ - code = 408 - def __init__(self, *args, **kwargs) -> None: kwargs.setdefault('headers', {})['Connection'] = 'close' super().__init__(*args, **kwargs) -class CONFLICT(ClientErrorStatus): +class CONFLICT(ClientErrorStatus, code=409): """ If the request would cause to leave the resource in an inconsequent state this status is send. @@ -127,35 +113,28 @@ class CONFLICT(ClientErrorStatus): The entity body should contain a description of the conflict. """ - code = 409 - -class GONE(ClientErrorStatus): +class GONE(ClientErrorStatus, code=410): """The resource exists but is not anymore available (propably DELETEd).""" - code = 410 cacheable = True -class LENGTH_REQUIRED(ClientErrorStatus): +class LENGTH_REQUIRED(ClientErrorStatus, code=411): """ If a request representation is given but no Content-Length-header the HTTP server can decide to respond with this status code. """ - code = 411 - -class PRECONDITION_FAILED(ClientErrorStatus): +class PRECONDITION_FAILED(ClientErrorStatus, code=412): r""" If a condition from any of the If-\*-headers except for conditional GET fails this status code is the respond. """ - code = 412 - -class PAYLOAD_TOO_LARGE(ClientErrorStatus): +class PAYLOAD_TOO_LARGE(ClientErrorStatus, code=413): """ The HTTP server can deny too large representations. A LBYL request can be useful. @@ -163,104 +142,75 @@ class PAYLOAD_TOO_LARGE(ClientErrorStatus): full disk space it can send the Retry-After-header. """ - code = 413 - -class URI_TOO_LONG(ClientErrorStatus): +class URI_TOO_LONG(ClientErrorStatus, code=414): """Raised if the given URI is too long for the server.""" - code = 414 - -class UNSUPPORTED_MEDIA_TYPE(ClientErrorStatus): +class UNSUPPORTED_MEDIA_TYPE(ClientErrorStatus, code=415): """ This status code is sent when the server does not know the representation media type given in Content-Type-header. If the representation is just broken use 400 or 422. """ - code = 415 +class RANGE_NOT_SATISFIABLE(ClientErrorStatus, code=416): + """Range not satisfiable.""" -class RANGE_NOT_SATISFIABLE(ClientErrorStatus): - code = 416 - - -class EXPECTATION_FAILED(ClientErrorStatus): +class EXPECTATION_FAILED(ClientErrorStatus, code=417): """ This is the response code if a LBYL request (Expect-header) fails. It is the flip side of 100 Continue. """ - code = 417 +class I_AM_A_TEAPOT(ClientErrorStatus, code=418): + """I am a teapot.""" -class I_AM_A_TEAPOT(ClientErrorStatus): - code = 418 +# class ENHANCE_YOUR_CALM(ClientErrorStatus, code=420): +# """Enhance our calm.""" -# class ENHANCE_YOUR_CALM(ClientErrorStatus): -# -# code = 420 - +class MISDIRECTED_REQUEST(ClientErrorStatus, code=421): + """Misredirected request.""" -class MISDIRECTED_REQUEST(ClientErrorStatus): - code = 421 +class UNPROCESSABLE_ENTITY(ClientErrorStatus, code=422): + """Unprocessable entity.""" -class UNPROCESSABLE_ENTITY(ClientErrorStatus): +class LOCKED(ClientErrorStatus, code=423): + """Locked.""" - code = 422 +class FAILED_DEPENDENCY(ClientErrorStatus, code=424): + """Failed dependency.""" -class LOCKED(ClientErrorStatus): - - code = 423 - - -class FAILED_DEPENDENCY(ClientErrorStatus): - - code = 424 - - -class UPGRADE_REQUIRED(ClientErrorStatus): - - code = 426 +class UPGRADE_REQUIRED(ClientErrorStatus, code=426): def __init__(self, upgrade: str, *args, **kwargs) -> None: kwargs.setdefault('headers', {})['Upgrade'] = upgrade kwargs['headers']['Connection'] = 'Upgrade' super().__init__(*args, **kwargs) -class PRECONDITION_REQUIRED(ClientErrorStatus): - - code = 428 +class PRECONDITION_REQUIRED(ClientErrorStatus, code=428): + """Precondition required.""" -class TOO_MANY_REQUESTS(ClientErrorStatus): +class TOO_MANY_REQUESTS(ClientErrorStatus, code=429): + """Too many requests.""" - code = 429 +class REQUEST_HEADER_FIELDS_TOO_LARGE(ClientErrorStatus, code=431): + """Request header fields too large.""" -class REQUEST_HEADER_FIELDS_TOO_LARGE(ClientErrorStatus): - code = 431 - - -# class NO_RESPONSE(ClientErrorStatus): -# -# code = 444 -# -# -# class UNAVAILABLE_FOR_LEGAL_REASONS(ClientErrorStatus): -# -# code = 451 -# +# class NO_RESPONSE(ClientErrorStatus, code=444): # -# class CLIENT_CLOSED_REQUEST(ClientErrorStatus): +# class UNAVAILABLE_FOR_LEGAL_REASONS(ClientErrorStatus, code=451): # -# code = 499 +# class CLIENT_CLOSED_REQUEST(ClientErrorStatus, code=499): diff --git a/httoop/status/informational.py b/httoop/status/informational.py index d30cf7fe..f04f3d02 100644 --- a/httoop/status/informational.py +++ b/httoop/status/informational.py @@ -8,7 +8,7 @@ class InformationalStatus(StatusException): """ -class CONTINUE(InformationalStatus): +class CONTINUE(InformationalStatus, code=100): """ This is, beside 417, the code for a LBYL (Look before you leap) request. It indicates that the request is OK and the client should resent its request. @@ -16,19 +16,17 @@ class CONTINUE(InformationalStatus): .. seealso:: :rfc:`2616#section-10.1` """ - code = 100 body = None -class SWITCHING_PROTOCOLS(InformationalStatus): +class SWITCHING_PROTOCOLS(InformationalStatus, code=101): """ If the client wants to use another protocol (in the Upgrade-header) this is the response that the TCP server now speaks another protocol. """ - code = 101 body = None -class PROCESSING(InformationalStatus): - code = 102 +class PROCESSING(InformationalStatus, code=102): + """Processing.""" diff --git a/httoop/status/redirect.py b/httoop/status/redirect.py index e4749c4d..53664402 100644 --- a/httoop/status/redirect.py +++ b/httoop/status/redirect.py @@ -26,17 +26,15 @@ def to_dict(self) -> dict[str, int | str | dict[str, str]]: return dct -class MULTIPLE_CHOICES(RedirectStatus): +class MULTIPLE_CHOICES(RedirectStatus, code=300): """ The server has multiple representations of the requested resource. And the client e.g. did not specify the Accept-header or the requested representation does not exists. """ - code = 300 - -class MOVED_PERMANENTLY(RedirectStatus): +class MOVED_PERMANENTLY(RedirectStatus, code=301): """ The the server knows the target resource but the URI is incorrect (wrong domain, trailing slash, etc.). @@ -44,17 +42,14 @@ class MOVED_PERMANENTLY(RedirectStatus): renamed to prevent broken links. """ - code = 301 cacheable = True -class FOUND(RedirectStatus): - - code = 302 +class FOUND(RedirectStatus, code=302): cacheable = True -class SEE_OTHER(RedirectStatus): +class SEE_OTHER(RedirectStatus, code=303): """ The request has been processed but instead of serving a representation of the result or resource it links to another @@ -65,11 +60,10 @@ class SEE_OTHER(RedirectStatus): . """ - code = 303 cacheable = True -class NOT_MODIFIED(RedirectStatus): +class NOT_MODIFIED(RedirectStatus, code=304): """ The client already has the data which is provided through the information in the Etag or If-Modified-Since-header. @@ -82,7 +76,6 @@ class NOT_MODIFIED(RedirectStatus): The response body has to be empty. """ - code = 304 body = None def __init__(self, *args, **kwargs) -> None: @@ -96,12 +89,11 @@ def __init__(self, *args, **kwargs) -> None: ) -class USE_PROXY(RedirectStatus): +class USE_PROXY(RedirectStatus, code=305): + """Use proxy.""" - code = 305 - -class TEMPORARY_REDIRECT(RedirectStatus): +class TEMPORARY_REDIRECT(RedirectStatus, code=307): """ The request has not processed because the requested resource is located at a different URI. @@ -110,11 +102,8 @@ class TEMPORARY_REDIRECT(RedirectStatus): important that the request was not processed. """ - code = 307 cacheable = True -class PERMANENT_REDIRECT(RedirectStatus): - - code = 308 +class PERMANENT_REDIRECT(RedirectStatus, code=308): cacheable = True diff --git a/httoop/status/server_error.py b/httoop/status/server_error.py index 19b61b31..f070cf9e 100644 --- a/httoop/status/server_error.py +++ b/httoop/status/server_error.py @@ -17,93 +17,74 @@ def to_dict(self) -> dict[str, str | int]: return dct -class INTERNAL_SERVER_ERROR(ServerErrorStatus): +class INTERNAL_SERVER_ERROR(ServerErrorStatus, code=500): """ The generic status code. Mostly used when an exception in the request handler occurs. """ - code = 500 cacheable = True -class NOT_IMPLEMENTED(ServerErrorStatus): +class NOT_IMPLEMENTED(ServerErrorStatus, code=501): """ The client tried to use a HTTP feature which the server does not support. Used if the server does not know the request method. """ - code = 501 - -class BAD_GATEWAY(ServerErrorStatus): +class BAD_GATEWAY(ServerErrorStatus, code=502): """Problem with the proxy server.""" - code = 502 cacheable = True -class SERVICE_UNAVAILABLE(ServerErrorStatus): +class SERVICE_UNAVAILABLE(ServerErrorStatus, code=503): """ There is currently a problem with the server. Probably too many requests at once. """ - code = 503 cacheable = True -class GATEWAY_TIMEOUT(ServerErrorStatus): +class GATEWAY_TIMEOUT(ServerErrorStatus, code=504): """The proxy could not connect to the upstream server.""" - code = 504 cacheable = True -class HTTP_VERSION_NOT_SUPPORTED(ServerErrorStatus): +class HTTP_VERSION_NOT_SUPPORTED(ServerErrorStatus, code=505): """ The clients http version is not supported. This should not happen since HTTP 1.1 is backward compatible. The entity-body should contain a list of supported protocols. """ - code = 505 - - -class VARIANT_ALSO_NEGOTIATES(ServerErrorStatus): - - code = 506 - - -class INSUFFICIENT_STORAGE(ServerErrorStatus): - - code = 507 +class VARIANT_ALSO_NEGOTIATES(ServerErrorStatus, code=506): + """Variant also negotiates.""" -class LOOP_DETECTED(ServerErrorStatus): - code = 508 +class INSUFFICIENT_STORAGE(ServerErrorStatus, code=507): + """Insufficient storage.""" -class BANDWIDTH_LIMIT_EXCEEDET(ServerErrorStatus): +class LOOP_DETECTED(ServerErrorStatus, code=508): + """Loop detected.""" - code = 509 +class BANDWIDTH_LIMIT_EXCEEDET(ServerErrorStatus, code=509): + """Bandwith limit exceedet.""" -# class NOT_EXTENDED(ServerErrorStatus): -# -# code = 510 +# class NOT_EXTENDED(ServerErrorStatus, code=510): -class NETWORK_AUTHENTICATION_REQUIRED(ServerErrorStatus): - code = 511 +class NETWORK_AUTHENTICATION_REQUIRED(ServerErrorStatus, code=511): + """Network authentication required.""" -# class NETWORK_READ_TIMEOUT_ERROR(ServerErrorStatus): -# -# code = 598 +# class NETWORK_READ_TIMEOUT_ERROR(ServerErrorStatus, code=598): -# class NETWORK_CONNECT_TIMEOUT_ERROR(ServerErrorStatus): -# -# code = 599 +# class NETWORK_CONNECT_TIMEOUT_ERROR(ServerErrorStatus, code=599): diff --git a/httoop/status/status.py b/httoop/status/status.py index deeb655c..6b1bf415 100644 --- a/httoop/status/status.py +++ b/httoop/status/status.py @@ -11,11 +11,14 @@ from typing import Any from httoop.exceptions import InvalidLine -from httoop.meta import HTTPSemantic +from httoop.meta import Semantic from httoop.util import _, integer -class Status(metaclass=HTTPSemantic): +STATUSES = {} + + +class Status(Semantic): """ A HTTP Status. @@ -69,6 +72,8 @@ def reason(self): def reason(self, reason) -> None: self.set((self.__code, reason)) + description = '' + STATUS_RE = re.compile(rb'^([1-5]\d{2})(?:\s+([\s\w]*))\Z') def __init__(self, code: int | None = None, reason: bytes | None = None) -> None: @@ -83,9 +88,39 @@ def __init__(self, code: int | None = None, reason: bytes | None = None) -> None self.__reason = '' reason = reason or '' reason = reason or reason or REASONS.get(code, ('', ''))[0] - if code: + if code is not None: self.set((code, reason)) + def __init_subclass__(cls, code=None, **kwargs): + super().__init_subclass__(**kwargs) + if code is None: + return + + if not (100 <= code <= 599): + raise RuntimeError('HTTP status code must be between 100 and 599', code, cls) + + if code < 200: + expected = 'InformationalStatus' + elif code < 300: + expected = 'SuccessStatus' + elif code < 400: + expected = 'RedirectStatus' + elif code < 500: + expected = 'ClientErrorStatus' + else: + expected = 'ServerErrorStatus' + + if not any(base.__name__ == expected for base in cls.__mro__): + raise RuntimeError(f'{cls.__name__} must inherit from {expected}') + + cls.code = code + reason, description = REASONS.get(code, ('', '')) + if cls.reason is Status.reason: + cls.reason = reason + if not cls.description: + cls.description = description + STATUSES[code] = cls + def parse(self, status: bytes) -> None: """ Parse a Statuscode and Reason-Phrase. @@ -132,7 +167,7 @@ def set(self, status: Any) -> None: :type status: int or tuple or bytes or Status """ - if isinstance(status, int) and 99 < status < 600: + if isinstance(status, int): self.__code, self.__reason = status, REASONS.get(status, ('', ''))[0] elif isinstance(status, tuple): code, reason = status @@ -148,6 +183,8 @@ def set(self, status: Any) -> None: self.__code, self.__reason = status.code, status.reason else: raise TypeError('invalid status') + if not (99 < self.__code < 600): + raise TypeError('invalid status') def __repr__(self) -> str: return '' % (self.__code, self.__reason) diff --git a/httoop/status/success.py b/httoop/status/success.py index 1b493c04..79f022a7 100644 --- a/httoop/status/success.py +++ b/httoop/status/success.py @@ -10,7 +10,7 @@ class SuccessStatus(StatusException): """ -class OK(SuccessStatus): +class OK(SuccessStatus, code=200): """ The request was successful. On GET requests the entity body will be a @@ -19,11 +19,10 @@ class OK(SuccessStatus): the current state of the resource or a description of the performed action. """ - code = 200 cacheable = True -class CREATED(SuccessStatus): +class CREATED(SuccessStatus, code=201): """ A new resource was created. This should only be send on POST and PUT requests. @@ -31,8 +30,6 @@ class CREATED(SuccessStatus): The entity-body should describe and link to the created resource. """ - code = 201 - def __init__(self, location: str, *args, **kwargs) -> None: kwargs.setdefault('headers', {})['Location'] = location super().__init__(*args, **kwargs) @@ -43,7 +40,7 @@ def to_dict(self) -> dict[str, int | str | dict[str, str]]: return dct -class ACCEPTED(SuccessStatus): +class ACCEPTED(SuccessStatus, code=202): """ The request looks valid but will be procecced later. It is an asynchronous action. @@ -53,20 +50,17 @@ class ACCEPTED(SuccessStatus): time when the request will be processed. """ - code = 202 - -class NON_AUTHORITATIVE_INFORMATION(SuccessStatus): +class NON_AUTHORITATIVE_INFORMATION(SuccessStatus, code=203): """ Everything is OK but the response headers may be altered by a third party. """ - code = 203 cacheable = True -class NO_CONTENT(SuccessStatus): +class NO_CONTENT(SuccessStatus, code=204): """ GET: The representation of the resource is empty. other request methods: the status message or representation is not needed. @@ -75,12 +69,11 @@ class NO_CONTENT(SuccessStatus): to a single record (a HTML POST form). """ - code = 204 body = None cacheable = True -class RESET_CONTENT(SuccessStatus): +class RESET_CONTENT(SuccessStatus, code=205): """ The same as 204 but this indicated that the client should reset the view of its data structure. @@ -88,11 +81,10 @@ class RESET_CONTENT(SuccessStatus): in succession (a HTML POST form). """ - code = 205 body = None -class PARTIAL_CONTENT(SuccessStatus): +class PARTIAL_CONTENT(SuccessStatus, code=206): """ Partial GET: The response does not contain the full representation of a resource @@ -102,10 +94,8 @@ class PARTIAL_CONTENT(SuccessStatus): and Content-Location-header are useful. """ - code = 206 - -class MULTI_STATUS(SuccessStatus): +class MULTI_STATUS(SuccessStatus, code=207): """ This status code indicated that the entity-body contains information about the states of the batch request. @@ -113,14 +103,12 @@ class MULTI_STATUS(SuccessStatus): The entity-body is descripted in RFC 2518. """ - code = 207 - - -class ALREADY_REPORTED(SuccessStatus): - code = 208 +class ALREADY_REPORTED(SuccessStatus, code=208): + """Already reported.""" -class IM_USED(SuccessStatus): +class IM_USED(SuccessStatus, code=226): + """I'm used.""" - code = 226 + reason = 'foo' diff --git a/httoop/status/types.py b/httoop/status/types.py index fd1ecefa..5169fcb0 100644 --- a/httoop/status/types.py +++ b/httoop/status/types.py @@ -6,39 +6,10 @@ from __future__ import annotations -from typing import Any - -from httoop.meta import HTTPSemantic -from httoop.status.status import REASONS, Status - - -class StatusType(HTTPSemantic): - - def __new__(cls: type, name: str, bases: Any, dict_: dict[str, Any]) -> Any: - code = int(dict_.get('code', 0)) - if 99 < code < 200: - scls = 'InformationalStatus' - elif code < 300: - scls = 'SuccessStatus' - elif code < 400: - scls = 'RedirectStatus' - elif code < 500: - scls = 'ClientErrorStatus' - elif code < 600: - scls = 'ServerErrorStatus' - else: - raise RuntimeError('A HTTP Status code can not be greater than 599 or lower than 100') - - if code and not any(scls == base.__name__ for base in bases): - raise RuntimeError(f'{name} must inherit from {scls}') +from httoop.status.status import Status - reason = REASONS.get(code, ('', '')) - dict_.setdefault('reason', reason[0]) - dict_.setdefault('description', reason[1]) - return super().__new__(cls, name, bases, dict_) - -class StatusException(Status, Exception, metaclass=StatusType): +class StatusException(Status, Exception): """ This class represents a small HTTP Response message for error handling purposes @@ -79,7 +50,7 @@ def traceback(self, tb) -> None: if self.server_error: self._traceback = tb - code = 0 + code = None def __init__(self, description: str | None = None, reason: None = None, headers: dict[str, str] | None = None, traceback: str | None = None) -> None: """ @@ -108,6 +79,8 @@ def __init__(self, description: str | None = None, reason: None = None, headers: if description is not None: self.description = description + else: + self.description = type(self).description if traceback: self.traceback = traceback diff --git a/httoop/uri/__init__.py b/httoop/uri/__init__.py index 7bc475f6..2fa7be23 100644 --- a/httoop/uri/__init__.py +++ b/httoop/uri/__init__.py @@ -4,9 +4,9 @@ .. seealso:: :rfc:`3986` """ +import httoop.uri.schemes # noqa: F401 from httoop.uri.http import HTTP, HTTPS -from httoop.uri.schemes import GitSSH from httoop.uri.uri import URI -__all__ = ('HTTP', 'HTTPS', 'URI', 'GitSSH') +__all__ = ('HTTP', 'HTTPS', 'URI') diff --git a/httoop/uri/http.py b/httoop/uri/http.py index b49f44c2..dfa54d7a 100644 --- a/httoop/uri/http.py +++ b/httoop/uri/http.py @@ -8,13 +8,11 @@ from httoop.uri.uri import URI -class HTTP(URI): +class HTTP(URI, scheme='http'): __slots__ = () - SCHEME = b'http' PORT = 80 -class HTTPS(HTTP): +class HTTPS(HTTP, scheme='https'): __slots__ = () - SCHEME = b'HTTPS' PORT = 443 diff --git a/httoop/uri/schemes.py b/httoop/uri/schemes.py index 708cb4dc..a9e65569 100644 --- a/httoop/uri/schemes.py +++ b/httoop/uri/schemes.py @@ -1,43 +1,36 @@ from httoop.uri.uri import URI -class GitSSH(URI): +class GitSSH(URI, scheme='git+ssh'): __slots__ = () - SCHEME = b'git+ssh' PORT = 22 -class SvnSSH(URI): +class SvnSSH(URI, scheme='svn+ssh'): __slots__ = () - SCHEME = b'svn+ssh' PORT = 22 -class IMAP(URI): +class IMAP(URI, scheme='imap'): __slots__ = () - SCHEME = b'imap' PORT = 143 -class NFS(URI): +class NFS(URI, scheme='nfs'): __slots__ = () - SCHEME = b'nfs' PORT = 2049 -class MMS(URI): +class MMS(URI, scheme='mms'): __slots__ = () - SCHEME = b'mms' PORT = 1755 -class FTP(URI): +class FTP(URI, scheme='ftp'): __slots__ = () - SCHEME = b'ftp' PORT = 21 -class LDAP(URI): +class LDAP(URI, scheme='ldap'): __slots__ = () - SCHEME = b'ldap' PORT = 389 diff --git a/httoop/uri/type.py b/httoop/uri/type.py deleted file mode 100644 index f4b0f5d5..00000000 --- a/httoop/uri/type.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from httoop.meta import HTTPSemantic - - -class URIType(HTTPSemantic): - - def __new__(mcs: type, name: str, bases: tuple[type], dict_: dict[str, str | tuple[()] | bytes | int]) -> Any: - cls = super().__new__(mcs, name, tuple(bases), dict_) - if dict_.get('SCHEME'): - for base in bases: - if getattr(base, 'SCHEMES', None) is not None: - base.SCHEMES.setdefault(dict_['SCHEME'].lower(), cls) - return cls diff --git a/httoop/uri/uri.py b/httoop/uri/uri.py index eed627c2..4dc3655a 100644 --- a/httoop/uri/uri.py +++ b/httoop/uri/uri.py @@ -12,9 +12,9 @@ from typing import TYPE_CHECKING, Any, Iterator from httoop.exceptions import InvalidURI +from httoop.meta import Semantic from httoop.uri.percent_encoding import Percent from httoop.uri.query_string import QueryString -from httoop.uri.type import URIType from httoop.util import _, integer @@ -22,14 +22,13 @@ from httoop.uri.http import HTTP -class URI(metaclass=URIType): +class URI(Semantic): """Uniform Resource Identifier.""" __slots__ = ('scheme', 'username', 'password', 'host', '_port', 'path', 'query_string', 'fragment') # noqa: RUF023 slots = __slots__ SCHEMES = {} - SCHEME = None PORT = None encoding = 'UTF-8' @@ -75,6 +74,12 @@ def port(self, port) -> None: def __init__(self, uri: Any | None = None, *args, **kwargs) -> None: self.set(kwargs or args or uri or b'') + def __init_subclass__(cls, *, scheme=None, **kwargs): + super().__init_subclass__(**kwargs) + + if scheme: + URI.SCHEMES.setdefault(scheme.lower(), cls) + def join(self, other: bytes | None = None, *args, **kwargs) -> HTTP | URI: """Join a URI with another absolute or relative URI.""" relative = URI(other or args or kwargs) @@ -359,10 +364,11 @@ def __eq__(self, other: object) -> bool: def __setattr__(self, name: str, value: Any) -> None: if name.startswith('_'): - return super().__setattr__(name, value) + super().__setattr__(name, value) + return if name == 'scheme' and value: - self.__class__ = self.SCHEMES.get(value if isinstance(value, bytes) else value.encode(), URI) + self.__class__ = self.SCHEMES.get(value.decode('ISO8859-1') if isinstance(value, bytes) else value, URI) if name in self.slots: if isinstance(value, bytes): @@ -376,7 +382,6 @@ def __setattr__(self, name: str, value: Any) -> None: raise TypeError(f'{name!r} must be string, not {type(value).__name__}') super().__setattr__(name, value) - return None def __repr__(self) -> str: return f'' diff --git a/pyproject.toml b/pyproject.toml index 314663a5..5fcd3a6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ test = [ ] [tool.pytest.ini_options] -addopts = "--tb=native --strict-markers --cov=httoop --cov-report=xml --cov-report=term-missing" +addopts = "-s -l -vv --tb=native --strict-markers --cov=httoop --cov-report=xml --cov-report=term-missing" [tool.setuptools.packages.find] where = ["."] diff --git a/tests/api/test_date.py b/tests/api/test_date.py index 0350800d..f698bb83 100644 --- a/tests/api/test_date.py +++ b/tests/api/test_date.py @@ -79,8 +79,3 @@ def test_invalid_date(invalid): with pytest.raises(TypeError): Date({}) - - -@pytest.mark.xfail(reason='currently it is not implemented in this way') -def test_not_existing_comparision(): - assert Date() == [] is None # noqa: F632 diff --git a/tests/api/test_repr.py b/tests/api/test_repr.py index aac8618f..149a1e6c 100644 --- a/tests/api/test_repr.py +++ b/tests/api/test_repr.py @@ -1,6 +1,5 @@ from httoop import Date from httoop.messages import Message -from httoop.meta import HTTPSemantic def test_repr(request_, response): @@ -14,10 +13,3 @@ def test_repr(request_, response): assert repr(request_.uri).startswith('') - - -def test_object_inheritance_removed(): - class Foo(metaclass=HTTPSemantic): - pass - - print(Foo.mro()) diff --git a/tests/api/test_status.py b/tests/api/test_status.py index 42264b8e..eef8d274 100644 --- a/tests/api/test_status.py +++ b/tests/api/test_status.py @@ -159,10 +159,15 @@ def test_status_type(response): assert response.status.server_error -@pytest.mark.parametrize('code', [99, 600, 1000]) +@pytest.mark.parametrize('code', [99, 600, 1000, 0]) def test_invalid_status_code(code, response): with pytest.raises(TypeError): response.status = code + with pytest.raises(TypeError): + response.status.code = code + from httoop.status import Status + with pytest.raises(TypeError): + Status(code) def test_invalid_status_subclasses(): @@ -170,13 +175,13 @@ def test_invalid_status_subclasses(): with pytest.raises(RuntimeError): - class MyServerError(ServerErrorStatus): - code = 600 + class MyServerError(ServerErrorStatus, code=600): + pass with pytest.raises(RuntimeError): - class MyInformational(ServerErrorStatus): - code = 100 + class MyInformational(ServerErrorStatus, code=100): + pass def test_created_location(): diff --git a/tests/tc2231/test_tc2231.py b/tests/tc2231/test_tc2231.py index 9e956ac9..1b402e7e 100644 --- a/tests/tc2231/test_tc2231.py +++ b/tests/tc2231/test_tc2231.py @@ -62,7 +62,7 @@ def test_attonlyquoted(content_disposition): content_disposition(b'Content-Disposition: "attachment"') -@pytest.mark.xfail(reason=AssertionError('Cannot test here')) +@pytest.mark.xfail(reason='Cannot test here') def test_attonly403(content_disposition): h = content_disposition(b'Content-Disposition: attachment') raise AssertionError('Cannot test here') @@ -338,7 +338,7 @@ def test_attconfusedparam(content_disposition): assert h.params['xfilename'] == 'foo.html' -@pytest.mark.xfail(reason=AssertionError('Cannot test here')) +@pytest.mark.xfail(reason='Cannot test here') def test_attabspath(content_disposition): h = content_disposition(b'Content-Disposition: attachment; filename="/foo.html"') assert h.attachment