Skip to content

Commit

Permalink
Merge pull request #27 from Ousret/develop
Browse files Browse the repository at this point in the history
Release 2.1.0
  • Loading branch information
Ousret authored May 5, 2020
2 parents 51c3168 + f678876 commit 929542d
Show file tree
Hide file tree
Showing 18 changed files with 344 additions and 54 deletions.
22 changes: 15 additions & 7 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
dist: xenial
language: python
cache: pip

python:
- "3.6"
- "3.7"
- "3.8"
- "3.9-dev"
- "pypy3"

jobs:
allow_failures:
- python: "3.9-dev" # See https://github.com/python/mypy/issues/8627
- python: "pypy3"

before_install:
- "pip install -U pip setuptools"
- "pip install -r requirements.txt"

install:
- pip install pytest pytest-cov codecov isort black requests mypy
- python setup.py install
- "python setup.py install"

script:
- "black --check --diff --target-version=py36 kiss_headers/"
- "black --check --diff --target-version=py36 tests/"
- "mypy kiss_headers"
- "isort --check-only kiss_headers/*.py"
- "pytest"
- export SOURCE_FILES="kiss_headers tests"
- black --check --diff --target-version=py36 $SOURCE_FILES
- mypy kiss_headers
- isort --check --diff --project=kiss_headers --recursive $SOURCE_FILES
- pytest

after_success:
- codecov
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,25 @@ headers.set_cookie[0]._1p_jar # output: 2020-03-16-21
headers.set_cookie[0]["1P_JAR"] # output: 2020-03-16-21
```

If this behaviour does bother you, you can lock output to always be a list. Just call `lock_output_type()` method.
Since v2.1 you can transform an Header object to its target `CustomHeader` subclass in order to access more methods.

```python
from kiss_headers import parse_it, get_polymorphic, SetCookie

my_cookies = """set-cookie: 1P_JAR=2020-03-16-21; expires=Wed, 15-Apr-2020 21:27:31 GMT; path=/; domain=.google.fr; Secure; SameSite=none
set-cookie: CONSENT=WP.284b10; expires=Fri, 01-Jan-2038 00:00:00 GMT; path=/; domain=.google.fr"""

headers = parse_it(my_cookies)

type(headers.set_cookie[0]) # output: Header

set_cookie = get_polymorphic(headers.set_cookie[0], SetCookie)

type(set_cookie) # output: SetCookie

set_cookie.get_cookie_name() # output: 1P_JAR
set_cookie.get_expire() # output: datetime(...)
```

Just a note: Accessing a header that has the same name as a reserved keyword must be done this way :
```python
Expand Down Expand Up @@ -186,8 +204,8 @@ See the full documentation for advanced usages : [www.kiss-headers.tech](https:/
Contributions, issues and feature requests are very much welcome.<br />
Feel free to check [issues page](https://github.com/Ousret/kiss-headers/issues) if you want to contribute.

Do not forget to run `pip install pytest pytest-cov codecov isort black requests mypy` before you start working on your fork.
Also check `.travis.yml` file to see what command is expected to return OK.
Firstly, after getting your own local copy, run `./scripts/install` to initialize your virtual environment.
Then run `./scripts/check` before you commit, make sure everything is still working.

## 📝 License

Expand Down
2 changes: 1 addition & 1 deletion kiss_headers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
:license: MIT, see LICENSE for more details.
"""

from kiss_headers.api import explain, parse_it
from kiss_headers.api import explain, get_polymorphic, parse_it
from kiss_headers.builder import (
Accept,
AcceptEncoding,
Expand Down
57 changes: 54 additions & 3 deletions kiss_headers/api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
from email.message import Message
from email.parser import HeaderParser
from io import RawIOBase
from typing import Any, Iterable, List, Mapping, Optional, Tuple
from typing import Any, Iterable, List, Mapping, Optional, Tuple, Type, TypeVar, Union

from kiss_headers.models import Header, Headers
from kiss_headers.structures import CaseInsensitiveDict
from kiss_headers.utils import (
class_to_header_name,
decode_partials,
extract_class_name,
extract_encoded_headers,
header_content_split,
header_name_to_class,
is_legal_header_name,
normalize_str,
)

T = TypeVar("T")


def parse_it(raw_headers: Any) -> Headers:
"""
Expand Down Expand Up @@ -79,8 +83,8 @@ def parse_it(raw_headers: Any) -> Headers:

entries: List[str] = header_content_split(content, ",")

# Multiple entries are detected in one content
if len(entries) > 1:
# Multiple entries are detected in one content at the only exception that its not IMAP header "Subject".
if len(entries) > 1 and normalize_str(head) != "subject":
for entry in entries:
list_of_headers.append(Header(head, entry))
else:
Expand Down Expand Up @@ -117,3 +121,50 @@ def explain(headers: Headers) -> CaseInsensitiveDict:
)

return explanations


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
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"]
'Wed, 15-Apr-2020 21:27:31 GMT'
>>> from kiss_headers import SetCookie
>>> set_cookie = get_polymorphic(header, SetCookie)
>>> set_cookie.get_expire()
datetime.datetime(2020, 4, 15, 21, 27, 31, tzinfo=datetime.timezone.utc)
"""

if not issubclass(desired_output, Header):
raise TypeError(
f"The desired output should be a subclass of Header not {desired_output}."
)

desired_output_header_name: str = class_to_header_name(desired_output)

if isinstance(target, Headers):
r = target.get(desired_output_header_name)

if r is None:
return None

elif isinstance(target, Header):
if header_name_to_class(target.name, Header) != desired_output:
raise TypeError(
f"The target class does not match the desired output class. {target.__class__} != {desired_output}."
)
r = target
else:
raise TypeError(f"Unable to apply get_polymorphic on type {target.__class__}.")

# Change __class__ attribute.
if not isinstance(r, list):
r.__class__ = desired_output
else:
for header in r:
header.__class__ = desired_output

return r # type: ignore
100 changes: 93 additions & 7 deletions kiss_headers/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ def __init__(
mime, **args,
)

def get_mime(self) -> Optional[str]:
"""Return defined mime in current accept header."""
for el in self.attrs:
if "/" in el:
return el
return None

def get_qualifier(self, _default: float = 1.0) -> Optional[float]:
"""Return defined qualifier for specified mime. If not set, output 1.0."""
return float(str(self["q"])) if self.has("q") else _default


class ContentType(CustomHeader):
"""
Expand Down Expand Up @@ -134,6 +145,17 @@ def __init__(

super().__init__(mime, **args)

def get_mime(self) -> Optional[str]:
"""Return defined mime in content type."""
for el in self.attrs:
if "/" in el:
return el
return None

def get_charset(self, _default: Optional[str] = "ISO-8859-1") -> Optional[str]:
"""Extract defined charset, if not present will return 'ISO-8859-1' by default."""
return str(self["charset"]) if self.has("charset") else _default


class XContentTypeOptions(CustomHeader):
"""
Expand Down Expand Up @@ -371,6 +393,10 @@ def __init__(self, my_date: Union[datetime, str], **kwargs: Optional[str]):
**kwargs,
)

def get_datetime(self) -> datetime:
"""Parse and return a datetime according to content."""
return utils.parsedate_to_datetime(str(self))


class CrossOriginResourcePolicy(CustomHeader):
"""
Expand Down Expand Up @@ -450,6 +476,29 @@ def __init__(self, algorithm: str, value: str, **kwargs: Optional[str]):
super().__init__("", **args)


class Cookie(CustomHeader):
"""The Cookie HTTP request header contains stored HTTP cookies previously sent by
the server with the Set-Cookie header."""

__tags__ = ["request"]

def __init__(self, **kwargs: Optional[str]):
"""
:param kwargs: Pair of cookie name associated with a value.
"""
super().__init__("", **kwargs)

def get_cookies_names(self) -> List[str]:
"""Retrieve all defined cookie names from Cookie header."""
return self.attrs

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


class SetCookie(CustomHeader):
"""
The Set-Cookie HTTP response header is used to send cookies from the server to the user agent,
Expand Down Expand Up @@ -517,6 +566,34 @@ def __init__(
if is_httponly:
self += "HttpOnly" # type: ignore

def is_http_only(self) -> bool:
"""Determine if the cookie can only be accessed by the browser."""
return "HttpOnly" in self

def is_secure(self) -> bool:
"""Determine if the cookie is TLS/SSL only."""
return "Secure" in self

def get_expire(self) -> Optional[datetime]:
"""Retrieve the parsed expiration date."""
return (
utils.parsedate_to_datetime(str(self["expires"]))
if self.has("expires")
else None
)

def get_max_age(self) -> Optional[int]:
"""Getting the max-age value as an integer if set."""
return int(str(self["max-age"])) if "max-age" in self else None

def get_cookie_name(self) -> str:
"""Extract the cookie name."""
return self.attrs[0]

def get_cookie_value(self) -> str:
"""Extract the cookie value."""
return str(self[self.get_cookie_name()])


class StrictTransportSecurity(CustomHeader):
"""
Expand Down Expand Up @@ -942,21 +1019,30 @@ class WwwAuthenticate(CustomHeader):
"""
The HTTP WWW-Authenticate response header defines the authentication
method that should be used to gain access to a resource.
Fair-Warning : This header is like none other and is harder to parse. It need a specific case.
"""

__tags__: List[str] = ["response"]
__squash__ = True

def __init__(
self,
auth_type: str,
realm: str,
charset: Optional[str] = None,
auth_type: Optional[str] = None,
challenge: str = "realm",
value: str = "Secured area",
**kwargs: Optional[str],
):
args: Dict = {"realm": realm, charset: charset.upper() if charset else None}
args.update(kwargs)

super().__init__(auth_type)
"""
>>> www_authenticate = WwwAuthenticate("Basic", "realm", "Secured area")
>>> repr(www_authenticate)
'Www-Authenticate: Basic realm="Secured area"'
>>> headers = www_authenticate + WwwAuthenticate(challenge="charset", value="UTF-8")
>>> repr(headers)
'Www-Authenticate: Basic realm="Secured area", charset="UTF-8"'
"""
super().__init__(
f'{auth_type+" " if auth_type else ""}{challenge}="{value}"', **kwargs
)


class XDnsPrefetchControl(CustomHeader):
Expand Down
34 changes: 31 additions & 3 deletions kiss_headers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ def __init__(self, name: str, content: str):
self._pretty_name: str = prettify_header_name(self._name)
self._content: str = content

self._members: List[str] = [
el.lstrip() for el in header_content_split(self._content, ";")
]
self._members: List[str] = header_content_split(self._content, ";")

self._not_valued_attrs: List[str] = list()
self._valued_attrs: MutableMapping[
Expand Down Expand Up @@ -131,6 +129,12 @@ def content(self) -> str:

return self._content

@property
def unfolded_content(self) -> str:
"""Output unfolded associated content to header. Meaning that every LF + n space(s) would be properly
replaced."""
return unfold(self.content)

@property
def comments(self) -> List[str]:
"""Retrieve comments in header content."""
Expand Down Expand Up @@ -290,6 +294,7 @@ def __setattr__(self, key: str, value: str) -> None:
"_members",
"_not_valued_attrs",
"_valued_attrs",
"__class__",
}:
return super().__setattr__(key, value)

Expand Down Expand Up @@ -993,6 +998,29 @@ def __contains__(self, item: Union[Header, str]) -> bool:

return False

def pop(self, __index_or_name: Union[str, int] = -1) -> Union[Header, List[Header]]:
"""Pop header from headers. By default the last one."""
if isinstance(__index_or_name, int):
return self._headers.pop(__index_or_name)
if isinstance(__index_or_name, str):
headers = self.get(__index_or_name)

if headers is None:
raise IndexError()

if isinstance(headers, list):
for header in headers:
self._headers.remove(header)
else:
self._headers.remove(headers)
return headers
raise TypeError

def popitem(self) -> Tuple[str, str]:
"""Pop last header as a tuple (header name, header content)."""
header: Header = self.pop() # type: ignore
return header.name, header.content

def __dir__(self) -> Iterable[str]:
"""
Provide a better auto-completion when using python interpreter. We are feeding __dir__ so Python can be aware
Expand Down
Loading

0 comments on commit 929542d

Please sign in to comment.