From e92963003c736d79b007d6ec15dd315e6a63bcca Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Fri, 15 May 2020 00:50:13 +0200 Subject: [PATCH 01/26] :pencil: Improve spelling/grammar in the docstring --- kiss_headers/api.py | 4 ++-- kiss_headers/models.py | 48 +++++++++++++++++++++--------------------- kiss_headers/utils.py | 22 +++++++++---------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/kiss_headers/api.py b/kiss_headers/api.py index 54b3ac7..351e202 100644 --- a/kiss_headers/api.py +++ b/kiss_headers/api.py @@ -126,8 +126,8 @@ def explain(headers: Headers) -> CaseInsensitiveDict: def get_polymorphic( target: Union[Headers, Header], desired_output: Type[T] ) -> Union[T, List[T], None]: - """Experimental. Transform an Header or Headers object to its target `CustomHeader` subclass - in order to access more ready-to-use methods. eg. You have an Header object named 'Set-Cookie' and you wish + """Experimental. Transform a Header or Headers object to its target `CustomHeader` subclass + to access more ready-to-use methods. eg. You have a Header object named 'Set-Cookie' and you wish to extract the expiration date as a datetime. >>> header = Header("Set-Cookie", "1P_JAR=2020-03-16-21; expires=Wed, 15-Apr-2020 21:27:31 GMT") >>> header["expires"] diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 4a7b243..904c281 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -97,7 +97,7 @@ def __init__(self, name: str, content: str): @property def name(self) -> str: """ - Output the original header name as it was captured initially + Output the original header name as it was captured initially. """ return self._name @@ -111,14 +111,14 @@ def normalized_name(self) -> str: @property def pretty_name(self) -> str: """ - Output a prettified name of the header. First letter capitalized of each word. + Output a prettified name of the header. The first letter capitalized on each word. """ return self._pretty_name @property def content(self) -> str: """ - Output associated content to header as it was captured initially. + Output associated content to the header as it was captured initially. >>> header = Header("ETag", '"33a64df551425fcc55e4d42a148795d9f25f89d4"') >>> header.content '33a64df551425fcc55e4d42a148795d9f25f89d4' @@ -131,7 +131,7 @@ def content(self) -> str: @property def unfolded_content(self) -> str: - """Output unfolded associated content to header. Meaning that every LF + n space(s) would be properly + """Output unfolded associated content to the header. Meaning that every LF + n space(s) would be properly replaced.""" return unfold(self.content) @@ -163,12 +163,12 @@ def __gt__(self, other: object) -> bool: return self.normalized_name > other.normalized_name def __deepcopy__(self, memodict: Dict) -> "Header": - """Simply provide a deepcopy of an Header object. Pointer/Reference free of the initial reference.""" + """Simply provide a deepcopy of a Header object. Pointer/Reference is free of the initial reference.""" return Header(deepcopy(self.name), deepcopy(self.content)) def __iadd__(self, other: Union[str, "Header"]) -> "Header": """ - Allow you to assign-add any string to an Header instance. The string will be a new member of your header. + Allow you to assign-add any string to a Header instance. The string will be a new member of your header. >>> header = Header("X-Hello-World", "") >>> repr(header) 'X-Hello-World: ' @@ -198,9 +198,9 @@ def __iadd__(self, other: Union[str, "Header"]) -> "Header": def __add__(self, other: Union[str, "Header"]) -> Union["Header", "Headers"]: """ - This implementation permit to add either a string or a Header to your Header instance. - When you add string to your Header instance, it will create another instance with a new - member in it using the string; see iadd doc about it. But when its another Header the result is an Headers + This implementation permits to add either a string or a Header to your Header instance. + When you add a string to your Header instance, it will create another instance with a new + member in it using the string; see iadd doc about it. But when its another Header the result is a Headers object containing both Header object. >>> headers = Header("X-Hello-World", "1") + Header("Content-Type", "happiness=True") >>> len(headers) @@ -234,7 +234,7 @@ def __add__(self, other: Union[str, "Header"]) -> Union["Header", "Headers"]: def __isub__(self, other: str) -> "Header": """ - This method should allow you to remove attribute or member from header. + This method should allow you to remove attributes or members from the header. """ if not isinstance(other, str): raise TypeError( @@ -273,7 +273,7 @@ def __isub__(self, other: str) -> "Header": def __sub__(self, other: str) -> "Header": """ - This method should allow you to remove attribute or member from header. + This method should allow you to remove attributes or members from the header. """ header_ = deepcopy(self) header_ -= other @@ -304,8 +304,8 @@ def __setattr__(self, key: str, value: str) -> None: def __setitem__(self, key: str, value: str) -> None: """ - Set an attribute bracket syntax like. This will erase previously set attribute named after the key. - Any value that are not a str are casted to str. + Set an attribute bracket syntax like. This will erase the previously set attribute named after the key. + Any values that are not str are cast to str. """ if key in self: @@ -323,7 +323,7 @@ def __setitem__(self, key: str, value: str) -> None: def __delitem__(self, key: str) -> None: """ - Remove any attribute named after the key in header using the bracket syntax. + Remove any attribute named after the key in the header using the bracket syntax. >>> headers = Header("Content-Type", "text/html; charset=UTF-8") + Header("Allow", "POST") >>> str(headers.content_type) 'text/html; charset=UTF-8' @@ -349,7 +349,7 @@ def __delitem__(self, key: str) -> None: def __delattr__(self, item: str) -> None: """ - Remove any attribute named after the key in header using the property notation. + Remove any attribute named after the key in the header using the property notation. >>> headers = Header("Content-Type", "text/html; charset=UTF-8") + Header("Vary", "Content-Type") >>> repr(headers.content_type) 'Content-Type: text/html; charset=UTF-8' @@ -369,8 +369,8 @@ def __delattr__(self, item: str) -> None: del self[item] def __iter__(self) -> Iterator[Tuple[str, Optional[Union[str, List[str]]]]]: - """Provide a way to iter over an Header object. This will yield a Tuple of key, value. - Value would be None if the key is a member without associated value.""" + """Provide a way to iter over a Header object. This will yield a Tuple of key, value. + The value would be None if the key is a member without associated value.""" for key in self._valued_attrs: yield key, self[key] for adjective in self._not_valued_attrs: @@ -405,7 +405,7 @@ def __eq__(self, other: object) -> bool: def __str__(self) -> str: """ - Allow to cast a single header to a string. Only content would be exposed here. + Allow casting a single header to a string. Only content would be exposed here. """ return self._content @@ -424,7 +424,7 @@ def __bytes__(self) -> bytes: def __dir__(self) -> Iterable[str]: """ - Provide a better auto-completion when using Python interpreter. We are feeding __dir__ so Python can be aware + Provide a better auto-completion when using a Python interpreter. We are feeding __dir__ so Python can be aware of what properties are callable. In other words, more precise auto-completion when not using IDE. """ return list(super().__dir__()) + [ @@ -442,13 +442,13 @@ def attrs(self) -> List[str]: def has(self, attr: str) -> bool: """ - Safely check is current header has an attribute or adjective in it. + Safely check if the current header has an attribute or adjective in it. """ return attr in self def get(self, attr: str) -> Optional[Union[str, List[str]]]: """ - Retrieve associated value of an attribute. + Retrieve the associated value of an attribute. >>> header = Header("Content-Type", "application/json; charset=UTF-8; format=flowed") >>> header.charset 'UTF-8' @@ -524,7 +524,7 @@ def __getattr__(self, item: str) -> Union[str, List[str]]: def __contains__(self, item: str) -> bool: """ - Verify if a string match a member or an attribute name of an Header. + Verify if a string matches a member or an attribute-name of a Header. """ if item in self.attrs: return True @@ -539,8 +539,8 @@ def __contains__(self, item: str) -> bool: class Headers(object): """ Object-oriented representation for Headers. Contains a list of Header with some level of abstraction. - Combine advantages of dict, CaseInsensibleDict, list, multi-dict and native objects. - Headers do not inherit of the Mapping type, but it does borrow some concept from it. + Combine advantages of dict, CaseInsensibleDict, list, multi-dict, and native objects. + Headers do not inherit the Mapping type, but it does borrow some concepts from it. """ # Most common headers that you may or may not find. This should be appreciated when having auto-completion. diff --git a/kiss_headers/utils.py b/kiss_headers/utils.py index 7d344fc..16e2969 100644 --- a/kiss_headers/utils.py +++ b/kiss_headers/utils.py @@ -36,9 +36,9 @@ def normalize_str(string: str) -> str: def unpack_protected_keyword(name: str) -> str: """ - By choice this project aim to allow developper to access header or attribute in header by using the property - notation. Some keyword are protected by the language itself. So : - When starting by a number, prepend a underscore to it. When using a protected keyword, append a underscore to it. + By choice, this project aims to allow developers to access header or attribute in header by using the property + notation. Some keywords are protected by the language itself. So : + When starting by a number, prepend an underscore to it. When using a protected keyword, append an underscore to it. >>> unpack_protected_keyword("_3to1") '3to1' >>> unpack_protected_keyword("from_") @@ -73,8 +73,8 @@ def extract_class_name(type_: Type) -> Optional[str]: def header_content_split(string: str, delimiter: str) -> List[str]: """ Take a string and split it according to the passed delimiter. - It will ignore delimiter if inside between double quote, inside a value or in parenthesis. - The input string is considered perfectly formed. This function do not split coma on a day + It will ignore delimiter if inside between double quote, inside a value, or in parenthesis. + The input string is considered perfectly formed. This function does not split coma on a day when attached, see "RFC 7231, section 7.1.1.2: Date". >>> header_content_split("Wed, 15-Apr-2020 21:27:31 GMT, Fri, 01-Jan-2038 00:00:00 GMT", ",") ['Wed, 15-Apr-2020 21:27:31 GMT', 'Fri, 01-Jan-2038 00:00:00 GMT'] @@ -233,7 +233,7 @@ def prettify_header_name(name: str) -> str: def decode_partials(items: Iterable[Tuple[str, Any]]) -> List[Tuple[str, str]]: """ - This function takes a list of tuple, representing headers by key, value. Where value is bytes or string containing + This function takes a list of tuples, representing headers by key, value. Where value is bytes or string containing (RFC 2047 encoded) partials fragments like the following : >>> decode_partials([("Subject", "=?iso-8859-1?q?p=F6stal?=")]) [('Subject', 'pöstal')] @@ -281,7 +281,7 @@ def unquote(string: str) -> str: def quote(string: str) -> str: """ - Surround string by double quote. + Surround string by a double quote char. >>> quote("hello") '"hello"' >>> quote('"hello') @@ -294,7 +294,7 @@ def quote(string: str) -> str: def count_leftover_space(content: str) -> int: """ - Recursive function that count trailing white space at the end of given string. + A recursive function that counts trailing white space at the end of the given string. >>> count_leftover_space("hello ") 3 >>> count_leftover_space("byebye ") @@ -389,8 +389,8 @@ def extract_comments(content: str) -> List[str]: def unfold(content: str) -> str: - """Some header content may have folded content (LF + 9 spaces, LF + 7 spaces or LF + 1 spaces) in it, making your job at reading them a little more difficult. - This function undo the folding in given content. + """Some header content may have folded content (LF + 9 spaces, LF + 7 spaces, or LF + 1 spaces) in it, making your job at reading them a little more difficult. + This function undoes the folding in the given content. >>> unfold("eqHS2AQD+hfNNlTiLej73CiBUGVQifX4watAaxUkdjGeH578i7n3Wwcdw2nLz+U0bH\\n ehSe/2QytZGWM5CewwNdumT1IVGzjFs+cRgfK0V6JlEIOoV3bRXxnjenWFfWdVNXtw8s") 'eqHS2AQD+hfNNlTiLej73CiBUGVQifX4watAaxUkdjGeH578i7n3Wwcdw2nLz+U0bHehSe/2QytZGWM5CewwNdumT1IVGzjFs+cRgfK0V6JlEIOoV3bRXxnjenWFfWdVNXtw8s' """ @@ -402,7 +402,7 @@ def unfold(content: str) -> str: def extract_encoded_headers(payload: bytes) -> Tuple[str, bytes]: - """This function purpose is to extract lines that can be decoded using utf-8. + """This function's purpose is to extract lines that can be decoded using the UTF-8 decoder. >>> extract_encoded_headers("Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n\\r\\n".encode("utf-8")) ('Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n', b'') >>> extract_encoded_headers("Host: developer.mozilla.org\\r\\nX-Hello-World: 死の漢字\\r\\n\\r\\nThat IS totally random.".encode("utf-8")) From ac2384bc7b79aa650437be52555fb738edac79a6 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Fri, 15 May 2020 00:56:13 +0200 Subject: [PATCH 02/26] :heavy_check_mark: Add test scenario for pop() method of Headers --- kiss_headers/models.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 904c281..ce6acdd 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -1068,6 +1068,25 @@ def pop(self, __index_or_name: Union[str, int] = -1) -> Union[Header, List[Heade """ Pop header instance(s) from headers. By default the last one. Accept index as integer or header name. If you pass a header name, it will pop from Headers every entry named likewise. + >>> headers = Header("A", "hello") + Header("B", "world") + Header("C", "funny; riddle") + >>> header = headers.pop() + >>> repr(header) + 'C: funny; riddle' + >>> headers = Header("A", "hello") + Header("B", "world") + Header("C", "funny; riddle") + >>> header = headers.pop(1) + >>> repr(header) + 'B: world' + >>> header = headers.pop("A") + >>> repr(header) + 'A: hello' + >>> headers = Header("A", "hello") + Header("B", "world") + Header("C", "funny; riddle") + Header("B", "ending") + >>> headers = headers.pop("B") + >>> len(headers) + 2 + >>> headers[0].name + 'B' + >>> (str(headers[0]), str(headers[1])) + ('world', 'ending') """ if isinstance(__index_or_name, int): return self._headers.pop(__index_or_name) From 096d897750e34f80ddc2f44ca92afbb317e393b6 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Sat, 16 May 2020 21:15:30 +0200 Subject: [PATCH 03/26] :sparkle: Implement insert() method on Headers class --- kiss_headers/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index ce6acdd..14b2e63 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -1020,6 +1020,13 @@ def __contains__(self, item: Union[Header, str]) -> bool: return False + def insert(self, __index: int, __header: Header) -> None: + """Insert header before the given index.""" + if not isinstance(__header, Header): + raise TypeError(f"Cannot insert element of type {type(__header)} in Headers.") + + self._headers.insert(__index, __header) + def index( self, __value: Union[Header, str], __start: int = 0, __stop: int = -1 ) -> int: From c69ee75c626ccf31f449eaa38a47a49bbf2dd203 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 18 May 2020 04:36:05 +0200 Subject: [PATCH 04/26] :sparkle: Introducing Attributes class in models (i) Should help maintain order within members (ii) Permit to alter its content based on index or key.. --- kiss_headers/models.py | 347 ++++++++++++++++++++++++++----------- kiss_headers/structures.py | 13 +- 2 files changed, 253 insertions(+), 107 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 14b2e63..31b2a76 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -1,24 +1,12 @@ from copy import deepcopy from json import dumps -from re import IGNORECASE, escape, findall -from typing import ( - Dict, - Iterable, - Iterator, - List, - MutableMapping, - Optional, - Tuple, - Type, - Union, -) +from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union -from kiss_headers.structures import CaseInsensitiveDict +from kiss_headers.structures import AttributeBag, CaseInsensitiveDict from kiss_headers.utils import ( extract_comments, header_content_split, header_name_to_class, - header_strip, is_legal_header_name, normalize_str, prettify_header_name, @@ -65,34 +53,7 @@ def __init__(self, name: str, content: str): self._members: List[str] = header_content_split(self._content, ";") - self._not_valued_attrs: List[str] = list() - self._valued_attrs: MutableMapping[ - str, Union[str, List[str]] - ] = CaseInsensitiveDict() - - for member in self._members: - if member == "": - continue - - if "=" in member: - key, value = tuple(member.split("=", maxsplit=1)) - - # avoid confusing base64 look alike single value for (key, value) - if value.count("=") == len(value) or len(value) == 0 or " " in key: - self._not_valued_attrs.append(unquote(member)) - continue - - if key not in self._valued_attrs: - self._valued_attrs[key] = value - else: - if isinstance(self._valued_attrs[key], str): - self._valued_attrs[key] = [self._valued_attrs[key], value] # type: ignore - else: - self._valued_attrs[key].append(value) # type: ignore - - continue - - self._not_valued_attrs.append(unquote(member)) + self._attrs: Attributes = Attributes(self._members) @property def name(self) -> str: @@ -190,9 +151,8 @@ def __iadd__(self, other: Union[str, "Header"]) -> "Header": ) ) - self._not_valued_attrs.append(other) - - self._content += "; " + other if self._content.lstrip() != "" else other + self._attrs.insert(other, None) + self._content = str(self._attrs) return self @@ -250,24 +210,8 @@ def __isub__(self, other: str) -> "Header": ) ) - other = normalize_str(other) - - if other in self._valued_attrs: - del self[other] - - if other in self._not_valued_attrs: - self._not_valued_attrs.remove(other) - while True: - try: - self._not_valued_attrs.remove(other) - except ValueError: - break - for elem in findall( - r"{member_name}(?=[;\n])".format(member_name=escape(other)), - self._content + "\n", - IGNORECASE, - ): - self._content = header_strip(self._content, elem) + self._attrs.remove(other) + self._content = str(self._attrs) return self @@ -292,8 +236,7 @@ def __setattr__(self, key: str, value: str) -> None: "_pretty_name", "_content", "_members", - "_not_valued_attrs", - "_valued_attrs", + "_attrs", "__class__", }: return super().__setattr__(key, value) @@ -313,13 +256,8 @@ def __setitem__(self, key: str, value: str) -> None: if not isinstance(value, str): value = str(value) - self._valued_attrs[key] = value - - self._content += '{semi_colon_r}{key}="{value}"'.format( - key=key, - value=value, - semi_colon_r="; " if self._content.lstrip() != "" else "", - ) + self._attrs.insert(key, value) + self._content = str(self._attrs) def __delitem__(self, key: str) -> None: """ @@ -331,21 +269,15 @@ def __delitem__(self, key: str) -> None: >>> str(headers.content_type) 'text/html' """ - if key not in self._valued_attrs: + if key not in self._attrs: raise KeyError( "'{item}' attribute is not defined within '{header}' header.".format( item=key, header=self.name ) ) - del self._valued_attrs[key] - - for elem in findall( - r"{key_name}=.*?(?=[;\n])".format(key_name=escape(key)), - self._content + "\n", - IGNORECASE, - ): - self._content = header_strip(self._content, elem) + self._attrs.remove(key) + self._content = str(self._attrs) def __delattr__(self, item: str) -> None: """ @@ -359,7 +291,7 @@ def __delattr__(self, item: str) -> None: """ item = normalize_str(item) - if item not in self._valued_attrs: + if item not in self._attrs: raise AttributeError( "'{item}' attribute is not defined within '{header}' header.".format( item=item, header=self.name @@ -371,10 +303,8 @@ def __delattr__(self, item: str) -> None: def __iter__(self) -> Iterator[Tuple[str, Optional[Union[str, List[str]]]]]: """Provide a way to iter over a Header object. This will yield a Tuple of key, value. The value would be None if the key is a member without associated value.""" - for key in self._valued_attrs: - yield key, self[key] - for adjective in self._not_valued_attrs: - yield adjective, None + for i in range(0, len(self._attrs)): + yield self._attrs[i] def __eq__(self, other: object) -> bool: """ @@ -382,20 +312,12 @@ def __eq__(self, other: object) -> bool: If testing against str, the first thing is to match it to raw content, if not equal verify if not in members. """ if isinstance(other, str): - return self.content == other or other in self._not_valued_attrs + return self.content == other or other in self._attrs if isinstance(other, Header): - if ( - self.normalized_name == other.normalized_name - and len(self._not_valued_attrs) == len(other._not_valued_attrs) - and len(self._valued_attrs) == len(other._valued_attrs) - ): - for adjective in self._not_valued_attrs: - if adjective not in other._not_valued_attrs: - return False - for key in self._valued_attrs: - if key not in other or self[key] != other[key]: - return False - return True + if self.normalized_name == other.normalized_name and len( + self._attrs + ) == len(other._attrs): + return self._attrs == other._attrs return False raise NotImplementedError( "Cannot compare type {type_} to an Header. Use str or Header.".format( @@ -428,7 +350,7 @@ def __dir__(self) -> Iterable[str]: of what properties are callable. In other words, more precise auto-completion when not using IDE. """ return list(super().__dir__()) + [ - normalize_str(key) for key in self._valued_attrs.keys() + normalize_str(key) for key in self._attrs.keys() ] @property @@ -438,7 +360,16 @@ def attrs(self) -> List[str]: eg. Content-Type: application/json; charset=utf-8; format=origin Would output : ['application/json', 'charset', 'format'] """ - return list(self._valued_attrs.keys()) + self._not_valued_attrs + attrs: List[str] = [] + + if len(self._attrs) == 0: + return attrs + + for i in range(0, len(self._attrs)): + attr, value = self._attrs[i] + attrs.append(attr) + + return attrs def has(self, attr: str) -> bool: """ @@ -459,9 +390,10 @@ def get(self, attr: str) -> Optional[Union[str, List[str]]]: >>> header.format 'flowed' """ - if attr not in self._valued_attrs: + if attr not in self._attrs: return None - return self._valued_attrs[attr] + + return self._attrs[attr] def has_many(self, name: str) -> bool: """ @@ -488,8 +420,8 @@ def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: self._members[item] if not OUTPUT_LOCK_TYPE else [self._members[item]] ) - if item in self._valued_attrs: - value = self._valued_attrs[item] + if item in self._attrs: + value = self._attrs[item] else: raise KeyError( "'{item}' attribute is not defined within '{header}' header.".format( @@ -513,7 +445,7 @@ def __getattr__(self, item: str) -> Union[str, List[str]]: """ item = unpack_protected_keyword(item) - if item not in self._valued_attrs: + if item not in self._attrs: raise AttributeError( "'{item}' attribute is not defined within '{header}' header.".format( item=item, header=self.name @@ -1133,6 +1065,209 @@ def __dir__(self) -> Iterable[str]: ) +class Attributes(object): + """ + Dedicated class to handle attributes within a Header. Wrap an AttributeBag and offer methods to manipulate it + with ease. + Store advanced info on attributes, case insensitive on keys and keep attrs ordering. + """ + + def __init__(self, members: List[str]): + self._bag: AttributeBag = CaseInsensitiveDict() + + for member, index in zip(members, range(0, len(members))): + + if member == "": + continue + + if "=" in member: + key, value = tuple(member.split("=", maxsplit=1)) + + # avoid confusing base64 look alike single value for (key, value) + if value.count("=") == len(value) or len(value) == 0 or " " in key: + self.insert(unquote(member), None) + continue + + self.insert(key, unquote(value)) + continue + + self.insert(unquote(member), None) + + def __str__(self) -> str: + """""" + content: str = "" + + if len(self._bag) == 0: + return content + + for i in range(0, len(self)): + key, value = self[i] + + if value is not None: + content += '{semi_colon_r}{key}="{value}"'.format( + key=key, value=value, semi_colon_r="; " if content != "" else "", + ) + else: + content += "; " + key if content != "" else key + + return content + + def keys(self) -> List[str]: + """""" + keys: List[str] = [] + + for index, key, value in self: + if key not in keys and value is not None: + keys.append(key) + + return keys + + def __eq__(self, other: object) -> bool: + """""" + if not isinstance(other, Attributes): + raise NotImplementedError + + if len(self._bag) != len(other._bag): + return False + + list_repr_a: List[Tuple[int, str, Optional[str]]] = list(self) + list_repr_b: List[Tuple[int, str, Optional[str]]] = list(other) + + list_check: List[Tuple[int, str, Optional[str]]] = [] + + for index_a, key_a, value_a in list_repr_a: + + key_a = normalize_str(key_a) + + for index_b, key_b, value_b in list_repr_b: + + key_b = normalize_str(key_b) + + if ( + key_a == key_b + and value_a == value_b + and (index_a, key_a, key_b) not in list_check + ): + + list_check.append((index_a, key_a, key_b)) + + return len(list_check) == len(list_repr_a) + + def __getitem__( + self, item: Union[int, str] + ) -> Union[Tuple[str, Optional[str]], Union[str, List[str]]]: + """""" + + if isinstance(item, str): + values: List[str] = [ + value for value in self._bag[item][0] if value is not None + ] + return values if len(values) > 1 else values[0] + + for attr in self._bag: + if item in self._bag[attr][1]: + pos: int = self._bag[attr][1].index(item) + return attr, self._bag[attr][0][pos] + + raise IndexError(f"{item} not in defined indexes.") + + def insert( + self, key: str, value: Optional[str], index: Optional[int] = None + ) -> None: + """""" + to_be_inserted: int = index if index is not None else len(self._bag) + + if index is not None: + for attr in self._bag: + values, indexes = self._bag[attr] + + for index_, cur in zip(indexes, range(0, len(indexes))): + if index_ >= index: + self._bag[attr][1][cur] += 1 + + if key not in self._bag: + self._bag[key] = ([value], [to_be_inserted]) + else: + self._bag[key][0].append(value) + self._bag[key][1].append(to_be_inserted) + + def remove(self, key: str, index: Optional[int] = None) -> None: + """""" + if key not in self._bag: + return + + freed_indexes: List[int] = [] + + if index is not None: + index = index if index >= 0 else index % (len(self)) + + pos: int = self._bag[key][1].index(index) + + self._bag[key][0].pop(pos) + + freed_indexes.append(self._bag[key][1].pop(pos)) + + if index is None or len(self._bag[key][0]) == 0: + freed_indexes += self._bag[key][1] + del self._bag[key] + + for attr in self._bag: + + values, indexes = self._bag[attr] + max_freed_index: int = max(freed_indexes) + + for index_, cur in zip(indexes, range(0, len(indexes))): + if index_ - 1 in freed_indexes: + self._bag[attr][1][cur] -= 1 + elif index_ > max_freed_index: + self._bag[attr][1][cur] -= 1 + + def __contains__(self, item: Union[str, Dict[str, List[str]]]) -> bool: + """""" + if len(self._bag) == 0: + return False + + if isinstance(item, str): + return item in self._bag + + target_key, target_value = item.popitem() + target_key = normalize_str(target_key) + + for i in range(0, len(self)): + key, value = self[i] + + if target_key == key and target_value == value: + return True + + return False + + @property + def last_index(self) -> Optional[int]: + if len(self._bag) == 0: + return None + + max_index: int = 0 + + for key in self._bag: + values, indexes = self._bag[key] + + maximum_ind_key: int = max(indexes) + + if maximum_ind_key > max_index: + max_index = maximum_ind_key + + return max_index + + def __len__(self) -> int: + last_index: Optional[int] = self.last_index + return last_index + 1 if last_index is not None else 0 + + def __iter__(self) -> Iterator[Tuple[int, str, Optional[str]]]: + for i in range(0, len(self)): + key, value = self[i] + yield i, key, value + + def lock_output_type(lock: bool = True) -> None: """ This method will restrict type entropy by always returning a List[Header] instead of Union[Header, List[Header]] diff --git a/kiss_headers/structures.py b/kiss_headers/structures.py index b5a432e..ba4a5dc 100644 --- a/kiss_headers/structures.py +++ b/kiss_headers/structures.py @@ -1,6 +1,13 @@ from collections import OrderedDict from collections.abc import Mapping, MutableMapping -from typing import Any, Iterator, Optional, Tuple +from typing import ( + Any, + Iterator, + List, + MutableMapping as MutableMappingType, + Optional, + Tuple, +) from kiss_headers.utils import normalize_str @@ -79,3 +86,7 @@ def copy(self) -> "CaseInsensitiveDict": def __repr__(self) -> str: return str(dict(self.items())) + + +AttributeDescription = Tuple[List[Optional[str]], List[int]] +AttributeBag = MutableMappingType[str, AttributeDescription] From 60a5b96f8d910571a777a873cfce51593c80ea64 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 18 May 2020 04:36:41 +0200 Subject: [PATCH 05/26] :sparkle: Add pop() and insert() methods on Header class --- kiss_headers/models.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 31b2a76..02dfead 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -127,6 +127,34 @@ def __deepcopy__(self, memodict: Dict) -> "Header": """Simply provide a deepcopy of a Header object. Pointer/Reference is free of the initial reference.""" return Header(deepcopy(self.name), deepcopy(self.content)) + def pop(self, __index: int) -> Tuple[str, Optional[str]]: + """Experimental.""" + + __index = __index if __index >= 0 else __index % len(self._attrs) + + key, value = self._attrs[__index] + + self._attrs.remove(key, __index) + self._content = str(self._attrs) + + return key, value + + def insert( + self, __index: int, *__members: str, **__attributes: Optional[str] + ) -> None: + """Experimental.""" + + __index = __index if __index >= 0 else __index % len(self._attrs) + + for member in __members: + self._attrs.insert(member, None, __index) + __index += 1 + for key, value in __attributes.items(): + self._attrs.insert(key, value, __index) + __index += 1 + + self._content = str(self._attrs) + def __iadd__(self, other: Union[str, "Header"]) -> "Header": """ Allow you to assign-add any string to a Header instance. The string will be a new member of your header. From 1cebe180972a3d4bb0e88113ed0c8b2bbc6d4de7 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 18 May 2020 04:37:04 +0200 Subject: [PATCH 06/26] :art: Minor code style issue in models.py --- kiss_headers/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 02dfead..25b572d 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -977,13 +977,14 @@ def __contains__(self, item: Union[Header, str]) -> bool: return True if isinstance(item, Header) and header == item: return True - return False def insert(self, __index: int, __header: Header) -> None: """Insert header before the given index.""" if not isinstance(__header, Header): - raise TypeError(f"Cannot insert element of type {type(__header)} in Headers.") + raise TypeError( + f"Cannot insert element of type {type(__header)} in Headers." + ) self._headers.insert(__index, __header) From f82efb7636130c71951d8de80298cf13103b7615 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 18 May 2020 04:37:49 +0200 Subject: [PATCH 07/26] :bookmark: Bump to version 2.2.0 --- kiss_headers/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiss_headers/version.py b/kiss_headers/version.py index f76ac80..770628f 100644 --- a/kiss_headers/version.py +++ b/kiss_headers/version.py @@ -2,5 +2,5 @@ Expose version """ -__version__ = "2.1.2" +__version__ = "2.2.0" VERSION = __version__.split(".") From 9f1c0727e608a4dd9359cbf3672b1c04cf696b82 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Mon, 18 May 2020 04:38:32 +0200 Subject: [PATCH 08/26] :pencil: Update README according to recent changes --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f430265..5ff5e4d 100644 --- a/README.md +++ b/README.md @@ -48,19 +48,20 @@ charset = headers['Content-Type'].split(';')[-1].split('=')[-1].replace('"', '') * A backwards-compatible syntax using bracket style. * Capability to alter headers using simple, human-readable operator notation `+` and `-`. -* Flexibility if headers are from IMAP4 or HTTP, use as you need with one library. -* Ability to parse any object and extract recognized headers from it, it also support UTF-8 encoded headers. +* Flexibility if headers are from an email or HTTP, use as you need with one library. +* Ability to parse any object and extract recognized headers from it, it also supports UTF-8 encoded headers. * Fully type-annotated. * Provide great auto-completion in Python interpreter or any capable IDE. -* Absolutely no dependencies. +* No dependencies. And never will be. * 90% test coverage. Plus all the features that you would expect from handling headers... * Properties syntax for headers and attribute in header. * Supports headers and attributes OneToOne, OneToMany and ManySquashedIntoOne. -* Capable of parsing `bytes`, `fp`, `str`, `dict`, `email.Message`, `requests.Response` and `httpx._models.Response`. -* Automatically unquote and unfold value of an attribute when retrieving it. +* Capable of parsing `bytes`, `fp`, `str`, `dict`, `email.Message`, `requests.Response`, `httpx._models.Response` and `urllib3.HTTPResponse`. +* Automatically unquote and unfold the value of an attribute when retrieving it. +* Keep headers and attributes ordering. * Case insensitive with header name and attribute key. * Character `-` equal `_` in addition of above feature. * Any syntax you like, we like. @@ -104,7 +105,7 @@ headers.set_cookie[0]._1p_jar # output: 2020-03-16-21 headers.set_cookie[0]["1P_JAR"] # output: 2020-03-16-21 ``` -Since v2.1 you can transform an Header object to its target `CustomHeader` subclass in order to access more methods. +Since v2.1 you can transform an Header object to its target `CustomHeader` subclass to access more methods. ```python from kiss_headers import parse_it, get_polymorphic, SetCookie From 620c74b475daffde134080cb39e9f5be42a76d34 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:43:21 +0200 Subject: [PATCH 09/26] :bug: Minor bug on eq and next index calc. (attributes) --- kiss_headers/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 25b572d..83cb0ff 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -1156,7 +1156,7 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, Attributes): raise NotImplementedError - if len(self._bag) != len(other._bag): + if len(self) != len(other): return False list_repr_a: List[Tuple[int, str, Optional[str]]] = list(self) @@ -1204,7 +1204,7 @@ def insert( self, key: str, value: Optional[str], index: Optional[int] = None ) -> None: """""" - to_be_inserted: int = index if index is not None else len(self._bag) + to_be_inserted: int = index if index is not None else len(self) if index is not None: for attr in self._bag: From 88099370aa63241daac4b3e7e89ec042edeb788b Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:49:43 +0200 Subject: [PATCH 10/26] :pencil: Add docstring in Attributes class --- kiss_headers/models.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 83cb0ff..5b31e5f 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -142,7 +142,7 @@ def pop(self, __index: int) -> Tuple[str, Optional[str]]: def insert( self, __index: int, *__members: str, **__attributes: Optional[str] ) -> None: - """Experimental.""" + """This method allows you to properly insert attributes into a Header instance.""" __index = __index if __index >= 0 else __index % len(self._attrs) @@ -1123,7 +1123,7 @@ def __init__(self, members: List[str]): self.insert(unquote(member), None) def __str__(self) -> str: - """""" + """Convert an Attributes instance to its string repr.""" content: str = "" if len(self._bag) == 0: @@ -1142,7 +1142,7 @@ def __str__(self) -> str: return content def keys(self) -> List[str]: - """""" + """This method return a list of attribute name that have at least one value associated to them.""" keys: List[str] = [] for index, key, value in self: @@ -1152,7 +1152,7 @@ def keys(self) -> List[str]: return keys def __eq__(self, other: object) -> bool: - """""" + """Verify if two instance of Attributes are equal. We don't care about ordering.""" if not isinstance(other, Attributes): raise NotImplementedError @@ -1251,8 +1251,18 @@ def remove(self, key: str, index: Optional[int] = None) -> None: elif index_ > max_freed_index: self._bag[attr][1][cur] -= 1 - def __contains__(self, item: Union[str, Dict[str, List[str]]]) -> bool: - """""" + def __contains__(self, item: Union[str, Dict[str, Union[List[str], str]]]) -> bool: + """Verify if a member/attribute/value is in an Attributes instance. See examples bellow : + >>> attributes = Attributes(["application/xml", "q=0.9", "q=0.1"]) + >>> "q" in attributes + True + >>> {"Q": "0.9"} in attributes + True + >>> "z" in attributes + False + >>> {"Q": "0.2"} in attributes + False + """ if len(self._bag) == 0: return False @@ -1272,6 +1282,7 @@ def __contains__(self, item: Union[str, Dict[str, List[str]]]) -> bool: @property def last_index(self) -> Optional[int]: + """Simply output the latest index used in attributes. Index start from zero.""" if len(self._bag) == 0: return None @@ -1288,10 +1299,13 @@ def last_index(self) -> Optional[int]: return max_index def __len__(self) -> int: + """The length of an Attributes instance is equal to the last index plus one. Not by keys() length.""" last_index: Optional[int] = self.last_index return last_index + 1 if last_index is not None else 0 def __iter__(self) -> Iterator[Tuple[int, str, Optional[str]]]: + """Provide an iterator over all attributes with or without associated value. + For each entry, output a tuple of index, attribute and a optional value.""" for i in range(0, len(self)): key, value = self[i] yield i, key, value From bf285bb63830e914a7628f00c92323834ac30f82 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:50:20 +0200 Subject: [PATCH 11/26] :sparkles: Expose Attributes class in the public package --- kiss_headers/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiss_headers/__init__.py b/kiss_headers/__init__.py index 658cac9..45c7f8d 100644 --- a/kiss_headers/__init__.py +++ b/kiss_headers/__init__.py @@ -2,7 +2,7 @@ Kiss-Headers ~~~~~~~~~~~~~~ -Kiss-Headers is a headers, HTTP or IMAP4 _(message, email)_ flavour, utility, written in Python, for humans. +Kiss-Headers is a headers, HTTP or IMAP4 _(message, email)_ flavour, utility, written in pure Python, for humans. Object oriented headers. Keep it sweet and simple. Basic usage: @@ -87,5 +87,5 @@ XFrameOptions, XXssProtection, ) -from kiss_headers.models import Header, Headers, lock_output_type +from kiss_headers.models import Header, Headers, Attributes, lock_output_type from kiss_headers.version import VERSION, __version__ From f34105fb95a846c69c25d4c53e1a2836865f1890 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:51:14 +0200 Subject: [PATCH 12/26] :sparkle: Improve pop() method on Header, use str or int as index. Like Headers class --- kiss_headers/models.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 5b31e5f..570f9bc 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -127,14 +127,26 @@ def __deepcopy__(self, memodict: Dict) -> "Header": """Simply provide a deepcopy of a Header object. Pointer/Reference is free of the initial reference.""" return Header(deepcopy(self.name), deepcopy(self.content)) - def pop(self, __index: int) -> Tuple[str, Optional[str]]: - """Experimental.""" - - __index = __index if __index >= 0 else __index % len(self._attrs) - - key, value = self._attrs[__index] + def pop(self, __index: Union[int, str] = -1) -> Tuple[str, Optional[Union[str, List[str]]]]: + """Permit to pop an element from a Header with a given index. + >>> header = Header("X", "a; b=k; h; h; z=0; y=000") + >>> header.pop(1) + ('b', 'k') + >>> header.pop() + ('y', '000') + >>> header.pop('z') + ('z', '0') + """ + + if isinstance(__index, int): + __index = __index if __index >= 0 else __index % len(self._attrs) + key, value = self._attrs[__index] + elif isinstance(__index, str): + key, value = __index, self._attrs[__index] + else: + raise ValueError(f"Cannot pop from Header using type {type(__index)}.") - self._attrs.remove(key, __index) + self._attrs.remove(key, __index if isinstance(__index, int) else None) self._content = str(self._attrs) return key, value From a22dee26f9939620d610efe512b8332266329b1b Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:55:06 +0200 Subject: [PATCH 13/26] :heavy_check_mark: Add tests scenarii regarding recent changes --- tests/test_attributes.py | 35 ++++++++++++++++++++ tests/test_header_order.py | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/test_attributes.py create mode 100644 tests/test_header_order.py diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000..354284e --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,35 @@ +import unittest +from kiss_headers import Attributes + + +class AttributesTestCase(unittest.TestCase): + def test_eq(self): + attr_a = Attributes(["a", "p=8a", "a", "XX"]) + attr_b = Attributes(["p=8a", "a", "a", "XX"]) + attr_c = Attributes(["p=8a", "a", "A", "Xx"]) + attr_d = Attributes(["p=8a", "a", "A", "Xx", "XX=a"]) + attr_e = Attributes(["p=8A", "a", "A", "Xx"]) + + self.assertEqual( + attr_a, + attr_b + ) + + self.assertEqual( + attr_a, + attr_c + ) + + self.assertNotEqual( + attr_a, + attr_d + ) + + self.assertNotEqual( + attr_a, + attr_e + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_header_order.py b/tests/test_header_order.py new file mode 100644 index 0000000..c4520f1 --- /dev/null +++ b/tests/test_header_order.py @@ -0,0 +1,67 @@ +import unittest +from kiss_headers import Header + + +class HeaderOrderingTest(unittest.TestCase): + + def test_keep_initial_order(self): + header = Header("Content-Type", "a; b=k; h; h; z=0") + + self.assertEqual( + ["a", "b", "h", "h", "z"], + header.attrs + ) + + def test_insertion_in_ordered_header(self): + header = Header("Content-Type", "a; b=k; h; h; z=0") + + header.insert(2, ppp="nt") + + self.assertEqual( + ["a", "b", "ppp", "h", "h", "z"], + header.attrs + ) + + def test_pop_in_ordered_header(self): + + header = Header("Content-Type", "a; b=k; h; h; z=0") + + key, value = header.pop(2) + + self.assertEqual( + key, + "h" + ) + + self.assertIsNone( + value + ) + + self.assertEqual( + ["a", "b", "h", "z"], + header.attrs + ) + + def test_pop_negative_index(self): + header = Header("Content-Type", "a; b=k; h; h; z=0") + + key, value = header.pop(-1) + + self.assertEqual( + key, + "z" + ) + + self.assertEqual( + value, + "0" + ) + + self.assertEqual( + ["a", "b", "h", "h"], + header.attrs + ) + + +if __name__ == '__main__': + unittest.main() From 48048c369f5d1b683718546bd9c351874817b621 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 00:57:55 +0200 Subject: [PATCH 14/26] :art: Reformat style on changes --- kiss_headers/__init__.py | 2 +- kiss_headers/models.py | 4 +++- tests/test_attributes.py | 22 +++++--------------- tests/test_header_order.py | 42 ++++++++------------------------------ 4 files changed, 18 insertions(+), 52 deletions(-) diff --git a/kiss_headers/__init__.py b/kiss_headers/__init__.py index 45c7f8d..e32deae 100644 --- a/kiss_headers/__init__.py +++ b/kiss_headers/__init__.py @@ -87,5 +87,5 @@ XFrameOptions, XXssProtection, ) -from kiss_headers.models import Header, Headers, Attributes, lock_output_type +from kiss_headers.models import Attributes, Header, Headers, lock_output_type from kiss_headers.version import VERSION, __version__ diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 570f9bc..3ebc950 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -127,7 +127,9 @@ def __deepcopy__(self, memodict: Dict) -> "Header": """Simply provide a deepcopy of a Header object. Pointer/Reference is free of the initial reference.""" return Header(deepcopy(self.name), deepcopy(self.content)) - def pop(self, __index: Union[int, str] = -1) -> Tuple[str, Optional[Union[str, List[str]]]]: + def pop( + self, __index: Union[int, str] = -1 + ) -> Tuple[str, Optional[Union[str, List[str]]]]: """Permit to pop an element from a Header with a given index. >>> header = Header("X", "a; b=k; h; h; z=0; y=000") >>> header.pop(1) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 354284e..d83d365 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -10,26 +10,14 @@ def test_eq(self): attr_d = Attributes(["p=8a", "a", "A", "Xx", "XX=a"]) attr_e = Attributes(["p=8A", "a", "A", "Xx"]) - self.assertEqual( - attr_a, - attr_b - ) + self.assertEqual(attr_a, attr_b) - self.assertEqual( - attr_a, - attr_c - ) + self.assertEqual(attr_a, attr_c) - self.assertNotEqual( - attr_a, - attr_d - ) + self.assertNotEqual(attr_a, attr_d) - self.assertNotEqual( - attr_a, - attr_e - ) + self.assertNotEqual(attr_a, attr_e) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_header_order.py b/tests/test_header_order.py index c4520f1..8472be2 100644 --- a/tests/test_header_order.py +++ b/tests/test_header_order.py @@ -3,24 +3,17 @@ class HeaderOrderingTest(unittest.TestCase): - def test_keep_initial_order(self): header = Header("Content-Type", "a; b=k; h; h; z=0") - self.assertEqual( - ["a", "b", "h", "h", "z"], - header.attrs - ) + self.assertEqual(["a", "b", "h", "h", "z"], header.attrs) def test_insertion_in_ordered_header(self): header = Header("Content-Type", "a; b=k; h; h; z=0") header.insert(2, ppp="nt") - self.assertEqual( - ["a", "b", "ppp", "h", "h", "z"], - header.attrs - ) + self.assertEqual(["a", "b", "ppp", "h", "h", "z"], header.attrs) def test_pop_in_ordered_header(self): @@ -28,40 +21,23 @@ def test_pop_in_ordered_header(self): key, value = header.pop(2) - self.assertEqual( - key, - "h" - ) + self.assertEqual(key, "h") - self.assertIsNone( - value - ) + self.assertIsNone(value) - self.assertEqual( - ["a", "b", "h", "z"], - header.attrs - ) + self.assertEqual(["a", "b", "h", "z"], header.attrs) def test_pop_negative_index(self): header = Header("Content-Type", "a; b=k; h; h; z=0") key, value = header.pop(-1) - self.assertEqual( - key, - "z" - ) + self.assertEqual(key, "z") - self.assertEqual( - value, - "0" - ) + self.assertEqual(value, "0") - self.assertEqual( - ["a", "b", "h", "h"], - header.attrs - ) + self.assertEqual(["a", "b", "h", "h"], header.attrs) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() From bdb8273437cb01a1f64a6df2a9870ffdccfa82ff Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 03:42:14 +0200 Subject: [PATCH 15/26] :poop: Chose to ignore mypy errors on this one. --- kiss_headers/models.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 3ebc950..71e0449 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -144,7 +144,7 @@ def pop( __index = __index if __index >= 0 else __index % len(self._attrs) key, value = self._attrs[__index] elif isinstance(__index, str): - key, value = __index, self._attrs[__index] + key, value = __index, self._attrs[__index] # type: ignore else: raise ValueError(f"Cannot pop from Header using type {type(__index)}.") @@ -346,7 +346,7 @@ def __iter__(self) -> Iterator[Tuple[str, Optional[Union[str, List[str]]]]]: """Provide a way to iter over a Header object. This will yield a Tuple of key, value. The value would be None if the key is a member without associated value.""" for i in range(0, len(self._attrs)): - yield self._attrs[i] + yield self._attrs[i] # type: ignore def __eq__(self, other: object) -> bool: """ @@ -435,7 +435,7 @@ def get(self, attr: str) -> Optional[Union[str, List[str]]]: if attr not in self._attrs: return None - return self._attrs[attr] + return self._attrs[attr] # type: ignore def has_many(self, name: str) -> bool: """ @@ -463,7 +463,7 @@ def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: ) if item in self._attrs: - value = self._attrs[item] + value = self._attrs[item] # type: ignore else: raise KeyError( "'{item}' attribute is not defined within '{header}' header.".format( @@ -471,13 +471,13 @@ def __getitem__(self, item: Union[str, int]) -> Union[str, List[str]]: ) ) - if OUTPUT_LOCK_TYPE and not isinstance(value, list): + if OUTPUT_LOCK_TYPE and isinstance(value, str): value = [value] return ( - unfold(unquote(value)) - if not isinstance(value, list) - else [unfold(unquote(v)) for v in value] + unfold(unquote(value)) # type: ignore + if isinstance(value, str) + else [unfold(unquote(v)) for v in value] # type: ignore ) def __getattr__(self, item: str) -> Union[str, List[str]]: From 280bb8347c8679c1204faf867a1d45d2c9b793f8 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Wed, 20 May 2020 03:44:22 +0200 Subject: [PATCH 16/26] :art: Apply isort on tests recent additions --- tests/test_attributes.py | 1 + tests/test_header_order.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index d83d365..da032c3 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,4 +1,5 @@ import unittest + from kiss_headers import Attributes diff --git a/tests/test_header_order.py b/tests/test_header_order.py index 8472be2..40f5e23 100644 --- a/tests/test_header_order.py +++ b/tests/test_header_order.py @@ -1,4 +1,5 @@ import unittest + from kiss_headers import Header From b4693b856b61a84ca945345646ca7d6235fadc73 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 00:01:01 +0200 Subject: [PATCH 17/26] :heavy_check_mark: Trying py 3.5 in travis --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 15ebde2..1411dd6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: python cache: pip python: + - "3.5" - "3.6" - "3.7" - "3.8" @@ -11,6 +12,7 @@ python: jobs: allow_failures: + - python: "3.5" - python: "3.9-dev" # See https://github.com/python/mypy/issues/8627 - python: "pypy3" From 022d3ad2bee7d3bea02b29ac29ddc66f3d9a56db Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 00:03:02 +0200 Subject: [PATCH 18/26] :fire: Remove httpx from dev deps --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 61cfb7d..de7c154 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ requests>=2.10 -httpx>=0.10 black pytest pytest-cov From 35df58346f2fa21701c540f0590d97fe1dd11c71 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 00:29:48 +0200 Subject: [PATCH 19/26] :bug: Unescape the double quote char in the cookie value --- kiss_headers/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiss_headers/builder.py b/kiss_headers/builder.py index 7df44a4..fd9e13e 100644 --- a/kiss_headers/builder.py +++ b/kiss_headers/builder.py @@ -639,7 +639,7 @@ def get_cookie_value( self, cookie_name: str, __default: Optional[str] = None ) -> Optional[str]: """Retrieve associated value with a given cookie name.""" - return str(self[cookie_name]) if cookie_name in self else __default + return str(self[cookie_name]).replace('\\"', "") if cookie_name in self else __default class SetCookie(CustomHeader): @@ -735,7 +735,7 @@ def get_cookie_name(self) -> str: def get_cookie_value(self) -> str: """Extract the cookie value.""" - return str(self[self.get_cookie_name()]) + return str(self[self.get_cookie_name()]).replace('\\"', "") class StrictTransportSecurity(CustomHeader): From f82dc4d9e15976e7b2cf4b0e7c45e418c592bd3f Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 00:30:55 +0200 Subject: [PATCH 20/26] :bug: Forgot to remove previous entries if any when using setitem on Header --- kiss_headers/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 71e0449..d0c2bb6 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -298,7 +298,9 @@ def __setitem__(self, key: str, value: str) -> None: if not isinstance(value, str): value = str(value) + self._attrs.remove(key) self._attrs.insert(key, value) + self._content = str(self._attrs) def __delitem__(self, key: str) -> None: From 5fbcce64835bb9ee9378a435221d1d472f6a4f28 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 00:32:04 +0200 Subject: [PATCH 21/26] :sparkle: Implement valued_attrs property on Header --- kiss_headers/models.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index d0c2bb6..85bf824 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -415,6 +415,28 @@ def attrs(self) -> List[str]: return attrs + @property + def valued_attrs(self) -> List[str]: + """ + List of distinct attributes that have at least one value associated with them. This list is ordered. + This property could have been written under the keys() method, but implementing it would interfere with dict() + cast and the __iter__() method. + eg. Content-Type: application/json; charset=utf-8; format=origin + Would output : ['charset', 'format'] + """ + attrs: List[str] = [] + + if len(self._attrs) == 0: + return attrs + + for i in range(0, len(self._attrs)): + attr, value = self._attrs[i] + + if value is not None and attr not in attrs: + attrs.append(attr) + + return attrs + def has(self, attr: str) -> bool: """ Safely check if the current header has an attribute or adjective in it. From 4b7a4d382edd4f4f78425580d8b09b8c36ba221a Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 01:04:16 +0200 Subject: [PATCH 22/26] :heavy_check_mark: Verify that attributes respond well to lock_output_type toggle --- tests/test_headers_from_string.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_headers_from_string.py b/tests/test_headers_from_string.py index 03e0961..3aa57f1 100644 --- a/tests/test_headers_from_string.py +++ b/tests/test_headers_from_string.py @@ -170,10 +170,14 @@ def test_fixed_type_output(self): self.assertEqual(1, len(headers.host)) + self.assertEqual(list, type(headers.accept[-1].q)) + lock_output_type(False) self.assertEqual(Header, type(headers.host)) + self.assertEqual(str, type(headers.accept[-1].q)) + if __name__ == "__main__": unittest.main() From 9219a43393df9d37d4674b3881090d654a4d14f3 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 01:05:04 +0200 Subject: [PATCH 23/26] :pencil: Add docstring in models.py about attrs, keys() and to_dict() regarding case sensitive dict. --- kiss_headers/models.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 85bf824..c54a0db 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -400,7 +400,7 @@ def __dir__(self) -> Iterable[str]: @property def attrs(self) -> List[str]: """ - List of members or attributes found in provided content. + List of members or attributes found in provided content. This list is ordered. eg. Content-Type: application/json; charset=utf-8; format=origin Would output : ['application/json', 'charset', 'format'] """ @@ -649,7 +649,8 @@ def __iter__(self) -> Iterator[Header]: def keys(self) -> List[str]: """ Return a list of distinct header name set in headers. - Be aware that it won't return a typing.KeysView + Be aware that it won't return a typing.KeysView. + Also this method allows you to create a case sensitive dict. """ keys = list() @@ -687,7 +688,8 @@ def to_dict(self) -> CaseInsensitiveDict: """ Provide a CaseInsensitiveDict output of current headers. This output type has been borrowed from psf/requests. If one header appears multiple times, it would be concatenated into the same value, separated by a comma. - Be aware that this repr could lead to a mistake. + Be aware that this repr could lead to a mistake. You could also cast a Headers instance to dict() to get a + case sensitive one. see method keys(). """ dict_headers = CaseInsensitiveDict() From 5346672d658929d92ce8a9997ddafc9d4a1cb6e9 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 01:17:38 +0200 Subject: [PATCH 24/26] :heavy_check_mark: Add test scenario where header have a space between members --- tests/test_header_operation.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_header_operation.py b/tests/test_header_operation.py index ff48806..3eb98ef 100644 --- a/tests/test_header_operation.py +++ b/tests/test_header_operation.py @@ -118,6 +118,37 @@ def test_simple_attr_add(self): self.assertEqual('text/html; charset="utf-8"; format="flowed"', content_type) + def test_contain_space_delimiter(self): + + authorization = Header("Authorization", "Bearer mysupersecrettoken") + + self.assertIn( + "Bearer", + authorization + ) + + self.assertIn( + "beaRer", + authorization + ) + + self.assertNotIn( + "beare", + authorization + ) + + self.assertFalse( + authorization == "Bearer" + ) + + self.assertTrue( + authorization == "bearer mysupersecrettoken" + ) + + self.assertFalse( + authorization == "basic mysupersecrettoken" + ) + if __name__ == "__main__": unittest.main() From 93e1871e46596873bb077b93e2c3958eb21157bf Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 01:18:07 +0200 Subject: [PATCH 25/26] :heavy_check_mark: Ignore some lines in coverage --- kiss_headers/api.py | 6 +++--- kiss_headers/models.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/kiss_headers/api.py b/kiss_headers/api.py index 351e202..7bf70a4 100644 --- a/kiss_headers/api.py +++ b/kiss_headers/api.py @@ -47,11 +47,11 @@ def parse_it(raw_headers: Any) -> Headers: for header_name in raw_headers.raw.headers: for header_content in raw_headers.raw.headers.getlist(header_name): headers.append((header_name, header_content)) - elif r in ["httpx._models.Response", "urllib3.response.HTTPResponse"]: + elif r in ["httpx._models.Response", "urllib3.response.HTTPResponse"]: # pragma: no cover headers = raw_headers.headers.items() if headers is None: - raise TypeError( + raise TypeError( # pragma: no cover "Cannot parse type {type_} as it is not supported by kiss-header.".format( type_=type(raw_headers) ) @@ -98,7 +98,7 @@ def explain(headers: Headers) -> CaseInsensitiveDict: Return a brief explanation of each header present in headers if available. """ if not Header.__subclasses__(): - raise LookupError( + raise LookupError( # pragma: no cover "You cannot use explain() function without properly importing the public package." ) diff --git a/kiss_headers/models.py b/kiss_headers/models.py index c54a0db..56b7e8c 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -109,7 +109,7 @@ def __lt__(self, other: object) -> bool: True """ if not isinstance(other, Header): - raise NotImplementedError + raise NotImplementedError # pragma: no cover return self.normalized_name < other.normalized_name def __gt__(self, other: object) -> bool: @@ -120,7 +120,7 @@ def __gt__(self, other: object) -> bool: False """ if not isinstance(other, Header): - raise NotImplementedError + raise NotImplementedError # pragma: no cover return self.normalized_name > other.normalized_name def __deepcopy__(self, memodict: Dict) -> "Header": @@ -146,7 +146,7 @@ def pop( elif isinstance(__index, str): key, value = __index, self._attrs[__index] # type: ignore else: - raise ValueError(f"Cannot pop from Header using type {type(__index)}.") + raise ValueError(f"Cannot pop from Header using type {type(__index)}.") # pragma: no cover self._attrs.remove(key, __index if isinstance(__index, int) else None) self._content = str(self._attrs) From 719b6f59e0b3365014235007cdd4e64b4b3fbf16 Mon Sep 17 00:00:00 2001 From: Ahmed TAHRI Date: Thu, 21 May 2020 01:24:27 +0200 Subject: [PATCH 26/26] :art: Reformat files --- kiss_headers/api.py | 5 ++++- kiss_headers/builder.py | 6 +++++- kiss_headers/models.py | 4 +++- tests/test_header_operation.py | 27 ++++++--------------------- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/kiss_headers/api.py b/kiss_headers/api.py index 7bf70a4..b1fc370 100644 --- a/kiss_headers/api.py +++ b/kiss_headers/api.py @@ -47,7 +47,10 @@ def parse_it(raw_headers: Any) -> Headers: for header_name in raw_headers.raw.headers: for header_content in raw_headers.raw.headers.getlist(header_name): headers.append((header_name, header_content)) - elif r in ["httpx._models.Response", "urllib3.response.HTTPResponse"]: # pragma: no cover + elif r in [ + "httpx._models.Response", + "urllib3.response.HTTPResponse", + ]: # pragma: no cover headers = raw_headers.headers.items() if headers is None: diff --git a/kiss_headers/builder.py b/kiss_headers/builder.py index fd9e13e..60907c8 100644 --- a/kiss_headers/builder.py +++ b/kiss_headers/builder.py @@ -639,7 +639,11 @@ def get_cookie_value( self, cookie_name: str, __default: Optional[str] = None ) -> Optional[str]: """Retrieve associated value with a given cookie name.""" - return str(self[cookie_name]).replace('\\"', "") if cookie_name in self else __default + return ( + str(self[cookie_name]).replace('\\"', "") + if cookie_name in self + else __default + ) class SetCookie(CustomHeader): diff --git a/kiss_headers/models.py b/kiss_headers/models.py index 56b7e8c..73f37dd 100644 --- a/kiss_headers/models.py +++ b/kiss_headers/models.py @@ -146,7 +146,9 @@ def pop( elif isinstance(__index, str): key, value = __index, self._attrs[__index] # type: ignore else: - raise ValueError(f"Cannot pop from Header using type {type(__index)}.") # pragma: no cover + raise ValueError( + f"Cannot pop from Header using type {type(__index)}." + ) # pragma: no cover self._attrs.remove(key, __index if isinstance(__index, int) else None) self._content = str(self._attrs) diff --git a/tests/test_header_operation.py b/tests/test_header_operation.py index 3eb98ef..3738f09 100644 --- a/tests/test_header_operation.py +++ b/tests/test_header_operation.py @@ -122,32 +122,17 @@ def test_contain_space_delimiter(self): authorization = Header("Authorization", "Bearer mysupersecrettoken") - self.assertIn( - "Bearer", - authorization - ) + self.assertIn("Bearer", authorization) - self.assertIn( - "beaRer", - authorization - ) + self.assertIn("beaRer", authorization) - self.assertNotIn( - "beare", - authorization - ) + self.assertNotIn("beare", authorization) - self.assertFalse( - authorization == "Bearer" - ) + self.assertFalse(authorization == "Bearer") - self.assertTrue( - authorization == "bearer mysupersecrettoken" - ) + self.assertTrue(authorization == "bearer mysupersecrettoken") - self.assertFalse( - authorization == "basic mysupersecrettoken" - ) + self.assertFalse(authorization == "basic mysupersecrettoken") if __name__ == "__main__":