__version__ = "2.2.1"
import re
import datetime
import logging
import sys
from unicodedata import normalize
if sys.version_info >= (3, 0, 0): from urllib.parse import (
quote as _default_quote, unquote as _default_unquote)
basestring = str
long = int
else: from urllib import (
quote as _default_quote, unquote as _default_unquote)
def _total_seconds(td):
if hasattr(td, "total_seconds"):
return td.total_seconds()
return td.days * 3600 * 24 + td.seconds + td.microseconds / 100000.0
default_cookie_quote = lambda item: _default_quote(
item, safe='!#$%&\'()*+/:<=>?@[]^`{|}~')
default_extension_quote = lambda item: _default_quote(
item, safe=' !"#$%&\'()*+,/:<=>?@[\\]^`{|}~')
default_unquote = _default_unquote
def _report_invalid_cookie(data):
"How this module logs a bad cookie when exception suppressed"
logging.error("invalid Cookie: %r", data)
def _report_unknown_attribute(name):
"How this module logs an unknown attribute when exception suppressed"
logging.error("unknown Cookie attribute: %r", name)
def _report_invalid_attribute(name, value, reason):
"How this module logs a bad attribute when exception suppressed"
logging.error("invalid Cookie attribute (%s): %r=%r", reason, name, value)
class CookieError(Exception):
def __init__(self):
Exception.__init__(self)
class InvalidCookieError(CookieError):
def __init__(self, data=None, message=""):
CookieError.__init__(self)
self.data = data
self.message = message
def __str__(self):
return '%r %r' % (self.message, self.data)
class InvalidCookieAttributeError(CookieError):
def __init__(self, name, value, reason=None):
CookieError.__init__(self)
self.name = name
self.value = value
self.reason = reason
def __str__(self):
prefix = ("%s: " % self.reason) if self.reason else ""
if self.name is None:
return '%s%r' % (prefix, self.value)
return '%s%r = %r' % (prefix, self.name, self.value)
class Definitions(object):
COOKIE_NAME = r"!#$%&'*+\-.0-9A-Z^_`a-z|~"
COOKIE_OCTET = r"\x21\x23-\x2B\--\x3A\x3C-\x5B\]-\x7E"
EXTENSION_AV = """ !"#$%&\\\\'()*+,\-./0-9:<=>?@A-Z[\\]^_`a-z{|}~"""
SET_COOKIE_HEADER = """(?x) # Verbose mode
^(?:Set-Cookie:[ ]*)?
(?P<name>[{name}:]+)
[ ]*=[ ]*
# Accept anything in quotes - this is not RFC 6265, but might ease
# working with older code that half-heartedly works with 2965. Accept
# spaces inside tokens up front, so we can deal with that error one
# cookie at a time, after this first pass.
(?P<value>(?:"{value}*")|(?:[{cookie_octet} ]*))
[ ]*
# Extract everything up to the end in one chunk, which will be broken
# down in the second pass. Don't match if there's any unexpected
# garbage at the end (hence the \Z; $ matches before newline).
(?P<attrs>(?:;[ ]*[{cookie_av}]+)*)
""".format(name=COOKIE_NAME, cookie_av=EXTENSION_AV + ";",
cookie_octet=COOKIE_OCTET, value="[^;]")
MAX_AGE_AV = "Max-Age=(?P<max_age>[\x30-\x39]+)"
LABEL = '{let_dig}(?:(?:{let_dig_hyp}+)?{let_dig})?'.format(
let_dig="[A-Za-z0-9]", let_dig_hyp="[0-9A-Za-z\-]")
DOMAIN = "\.?(?:{label}\.)*(?:{label})".format(label=LABEL)
DOMAIN_AV = "Domain=(?P<domain>{domain})".format(domain=DOMAIN)
PATH_AV = 'Path=(?P<path>[%s]+)' % EXTENSION_AV
month_list = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November",
"December"]
month_abbr_list = [item[:3] for item in month_list]
month_numbers = {}
for index, name in enumerate(month_list):
name = name.lower()
month_numbers[name[:3]] = index + 1
month_numbers[name] = index + 1
MONTH_SHORT = "(?:" + "|".join(item[:3] for item in month_list) + ")"
MONTH_LONG = "(?:" + "|".join(item for item in month_list) + ")"
weekday_list = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday"]
weekday_abbr_list = [item[:3] for item in weekday_list]
WEEKDAY_SHORT = "(?:" + "|".join(item[:3] for item in weekday_list) + ")"
WEEKDAY_LONG = "(?:" + "|".join(item for item in weekday_list) + ")"
DAY_OF_MONTH = "(?:[0 ]?[1-9]|[12][0-9]|[3][01])(?!\d)"
DATE = """(?ix) # Case-insensitive mode, verbose mode
(?:
(?P<weekday>(?:{wdy}|{weekday}),[ ])?
(?P<day>{day})
[ \-]
(?P<month>{mon}|{month})
[ \-]
# This does not support 3-digit years, which are rare and don't
# seem to have one canonical interpretation.
(?P<year>(?:\d{{2}}|\d{{4}}))
[ ]
# HH:MM[:SS] GMT
(?P<hour>(?:[ 0][0-9]|[01][0-9]|2[0-3]))
:(?P<minute>(?:0[0-9]|[1-5][0-9]))
(?::(?P<second>\d{{2}}))?
[ ]GMT
|
# Support asctime format, e.g. 'Sun Nov 6 08:49:37 1994'
(?P<weekday2>{wdy})[ ]
(?P<month2>{mon})[ ]
(?P<day2>[ ]\d|\d\d)[ ]
(?P<hour2>\d\d):
(?P<minute2>\d\d)
(?::(?P<second2>\d\d)?)[ ]
(?P<year2>\d\d\d\d)
(?:[ ]GMT)? # GMT (Amazon)
)
"""
DATE = DATE.format(wdy=WEEKDAY_SHORT, weekday=WEEKDAY_LONG,
day=DAY_OF_MONTH, mon=MONTH_SHORT, month=MONTH_LONG)
EXPIRES_AV = "Expires=(?P<expires>%s)" % DATE
ATTR = """(?ix) # Case-insensitive mode, verbose mode
# Always start with start or semicolon and any number of spaces
(?:^|;)[ ]*(?:
# Big disjunction of attribute patterns (*_AV), with named capture
# groups to extract everything in one pass. Anything unrecognized
# goes in the 'unrecognized' capture group for reporting.
{expires}
|{max_age}
|{domain}
|{path}
|(?P<secure>Secure=?)
|(?P<httponly>HttpOnly=?)
|Version=(?P<version>[{stuff}]+)
|Comment=(?P<comment>[{stuff}]+)
|(?P<unrecognized>[{stuff}]+)
)
# End with any number of spaces not matched by the preceding (up to the
# next semicolon) - but do not capture these.
[ ]*
""".format(expires=EXPIRES_AV, max_age=MAX_AGE_AV, domain=DOMAIN_AV,
path=PATH_AV, stuff=EXTENSION_AV)
COOKIE = """(?x) # Verbose mode
(?: # Either something close to valid...
# Match starts at start of string, or at separator.
# Split on comma for the sake of legacy code (RFC 2109/2965),
# and since it only breaks when invalid commas are put in values.
# see http://bugs.python.org/issue1210326
(?:^Cookie:|^|;|,)
# 1 or more valid token characters making up the name (captured)
# with colon added to accommodate users of some old Java apps, etc.
[ ]*
(?P<name>[{name}:]+)
[ ]*
=
[ ]*
# While 6265 provides only for cookie-octet, this allows just about
# anything in quotes (like in RFC 2616); people stuck on RFC
# 2109/2965 will expect it to work this way. The non-quoted token
# allows interior spaces ('\x20'), which is not valid. In both
# cases, the decision of whether to allow these is downstream.
(?P<value>
["][^\00-\31"]*["]
|
[{value}]
|
[{value}][{value} ]*[{value}]+
|
)
# ... Or something way off-spec - extract to report and move on
|
(?P<invalid>[^;]+)
)
# Trailing spaces after value
[ ]*
# Must end with ; or be at end of string (don't consume this though,
# so use the lookahead assertion ?=
(?=;|\Z)
""".format(name=COOKIE_NAME, value=COOKIE_OCTET)
COOKIE_NAME_RE = re.compile("^([%s:]+)\Z" % COOKIE_NAME)
COOKIE_RE = re.compile(COOKIE)
SET_COOKIE_HEADER_RE = re.compile(SET_COOKIE_HEADER)
ATTR_RE = re.compile(ATTR)
DATE_RE = re.compile(DATE)
DOMAIN_RE = re.compile(DOMAIN)
PATH_RE = re.compile('^([%s]+)\Z' % EXTENSION_AV)
EOL = re.compile("(?:\r\n|\n)")
def strip_spaces_and_quotes(value):
value = value.strip() if value else ""
if value and len(value) > 1 and (value[0] == value[-1] == '"'):
value = value[1:-1]
if not value:
value = ""
return value
def parse_string(data, unquote=default_unquote):
if data is None:
return None
if isinstance(data, bytes):
if sys.version_info > (3, 0, 0): data = data.decode('ascii')
unquoted = unquote(data)
if isinstance(unquoted, bytes):
unquoted = unquoted.decode('utf-8')
return unquoted
def parse_date(value):
match = Definitions.DATE_RE.match(value) if value else None
if not match:
return None
data = {}
captured = match.groupdict()
fields = ['year', 'month', 'day', 'hour', 'minute', 'second']
if captured['year']:
for field in fields:
data[field] = captured[field]
else:
for field in fields:
data[field] = captured[field + "2"]
year = data['year']
if len(year) == 2:
if int(year) < 70:
year = "20" + year
else:
year = "19" + year
year = int(year)
data['year'] = max(1900, min(year, 9999))
for field in ['day', 'hour', 'minute', 'second']:
if data[field] is None:
data[field] = 0
data[field] = int(data[field])
data['month'] = Definitions.month_numbers[data['month'].lower()]
return datetime.datetime(**data)
def parse_domain(value):
value = strip_spaces_and_quotes(value)
if value:
assert valid_domain(value)
return value
def parse_path(value):
value = strip_spaces_and_quotes(value)
assert valid_path(value)
return value
def parse_value(value, allow_spaces=True, unquote=default_unquote):
"Process a cookie value"
if value is None:
return None
value = strip_spaces_and_quotes(value)
value = parse_string(value, unquote=unquote)
if not allow_spaces:
assert ' ' not in value
return value
def valid_name(name):
"Validate a cookie name string"
if isinstance(name, bytes):
name = name.decode('ascii')
if not Definitions.COOKIE_NAME_RE.match(name):
return False
if name[0] == "$":
return False
return True
def valid_value(value, quote=default_cookie_quote, unquote=default_unquote):
if value is None:
return False
encoded = encode_cookie_value(value, quote=quote)
decoded = parse_string(encoded, unquote=unquote)
decoded_normalized = (normalize("NFKD", decoded)
if not isinstance(decoded, bytes) else decoded)
value_normalized = (normalize("NFKD", value)
if not isinstance(value, bytes) else value)
if decoded_normalized == value_normalized:
return True
return False
def valid_date(date):
"Validate an expires datetime object"
if not hasattr(date, 'tzinfo'):
return False
if date.tzinfo is None or _total_seconds(date.utcoffset()) < 1.1:
return True
return False
def valid_domain(domain):
"Validate a cookie domain ASCII string"
domain.encode('ascii')
if Definitions.DOMAIN_RE.match(domain):
return True
return False
def valid_path(value):
"Validate a cookie path ASCII string"
value.encode("ascii")
if not (value and value[0] == "/"):
return False
if not Definitions.PATH_RE.match(value):
return False
return True
def valid_max_age(number):
"Validate a cookie Max-Age"
if isinstance(number, basestring):
try:
number = long(number)
except (ValueError, TypeError):
return False
if number >= 0 and number % 1 == 0:
return True
return False
def encode_cookie_value(data, quote=default_cookie_quote):
if data is None:
return None
if not isinstance(data, bytes):
data = data.encode("utf-8")
quoted = quote(data)
return quoted
def encode_extension_av(data, quote=default_extension_quote):
if not data:
return ''
return quote(data)
def render_date(date):
if not date:
return None
assert valid_date(date)
weekday = Definitions.weekday_abbr_list[date.weekday()]
month = Definitions.month_abbr_list[date.month - 1]
return date.strftime("{day}, %d {month} %Y %H:%M:%S GMT"
).format(day=weekday, month=month)
def render_domain(domain):
if not domain:
return None
if domain[0] == '.':
return domain[1:]
return domain
def _parse_request(header_data, ignore_bad_cookies=False):
cookies_dict = {}
for line in Definitions.EOL.split(header_data.strip()):
matches = Definitions.COOKIE_RE.finditer(line)
matches = [item for item in matches]
for match in matches:
invalid = match.group('invalid')
if invalid:
if not ignore_bad_cookies:
raise InvalidCookieError(data=invalid)
_report_invalid_cookie(invalid)
continue
name = match.group('name')
values = cookies_dict.get(name)
value = match.group('value').strip('"')
if values:
values.append(value)
else:
cookies_dict[name] = [value]
if not matches:
if not ignore_bad_cookies:
raise InvalidCookieError(data=line)
_report_invalid_cookie(line)
return cookies_dict
def parse_one_response(line, ignore_bad_cookies=False,
ignore_bad_attributes=True):
cookie_dict = {}
match = Definitions.SET_COOKIE_HEADER_RE.match(line)
if not match:
if not ignore_bad_cookies:
raise InvalidCookieError(data=line)
_report_invalid_cookie(line)
return None
cookie_dict.update({
'name': match.group('name'),
'value': match.group('value')})
for match in Definitions.ATTR_RE.finditer(match.group('attrs')):
captured = dict((k, v) for (k, v) in match.groupdict().items() if v)
unrecognized = captured.get('unrecognized', None)
if unrecognized:
if not ignore_bad_attributes:
raise InvalidCookieAttributeError(None, unrecognized,
"unrecognized")
_report_unknown_attribute(unrecognized)
continue
for key in ('secure', 'httponly'):
if captured.get(key):
captured[key] = True
timekeys = ('weekday', 'month', 'day', 'hour', 'minute', 'second',
'year')
if 'year' in captured:
for key in timekeys:
del captured[key]
elif 'year2' in captured:
for key in timekeys:
del captured[key + "2"]
cookie_dict.update(captured)
return cookie_dict
def _parse_response(header_data, ignore_bad_cookies=False,
ignore_bad_attributes=True):
cookie_dicts = []
for line in Definitions.EOL.split(header_data.strip()):
if not line:
break
cookie_dict = parse_one_response(
line, ignore_bad_cookies=ignore_bad_cookies,
ignore_bad_attributes=ignore_bad_attributes)
if not cookie_dict:
continue
cookie_dicts.append(cookie_dict)
if not cookie_dicts:
if not ignore_bad_cookies:
raise InvalidCookieError(data=header_data)
_report_invalid_cookie(header_data)
return cookie_dicts
class Cookie(object):
def __init__(self, name, value, **kwargs):
try:
self.name = name
except InvalidCookieAttributeError:
raise InvalidCookieError(message="invalid name for new Cookie",
data=name)
value = value or ''
try:
self.value = value
except InvalidCookieAttributeError:
raise InvalidCookieError(message="invalid value for new Cookie",
data=value)
if kwargs:
self._set_attributes(kwargs, ignore_bad_attributes=False)
def _set_attributes(self, attrs, ignore_bad_attributes=False):
for attr_name, attr_value in attrs.items():
if not attr_name in self.attribute_names:
if not ignore_bad_attributes:
raise InvalidCookieAttributeError(
attr_name, attr_value,
"unknown cookie attribute '%s'" % attr_name)
_report_unknown_attribute(attr_name)
try:
setattr(self, attr_name, attr_value)
except InvalidCookieAttributeError as error:
if not ignore_bad_attributes:
raise
_report_invalid_attribute(attr_name, attr_value, error.reason)
continue
@classmethod
def from_dict(cls, cookie_dict, ignore_bad_attributes=True):
name = cookie_dict.get('name', None)
if not name:
raise InvalidCookieError("Cookie must have name")
raw_value = cookie_dict.get('value', '')
value = cls.attribute_parsers['value'](raw_value)
cookie = cls(name, value)
parsed = {}
for key, value in cookie_dict.items():
if key in ('name', 'value'):
continue
parser = cls.attribute_parsers.get(key)
if not parser:
if not ignore_bad_attributes:
raise InvalidCookieAttributeError(
key, value, "unknown cookie attribute '%s'" % key)
_report_unknown_attribute(key)
continue
try:
parsed_value = parser(value)
except Exception as e:
reason = "did not parse with %r: %r" % (parser, e)
if not ignore_bad_attributes:
raise InvalidCookieAttributeError(
key, value, reason)
_report_invalid_attribute(key, value, reason)
parsed_value = ''
parsed[key] = parsed_value
cookie._set_attributes(parsed, ignore_bad_attributes)
return cookie
@classmethod
def from_string(cls, line, ignore_bad_cookies=False,
ignore_bad_attributes=True):
"Construct a Cookie object from a line of Set-Cookie header data."
cookie_dict = parse_one_response(
line, ignore_bad_cookies=ignore_bad_cookies,
ignore_bad_attributes=ignore_bad_attributes)
if not cookie_dict:
return None
return cls.from_dict(
cookie_dict, ignore_bad_attributes=ignore_bad_attributes)
def to_dict(self):
this_dict = {'name': self.name, 'value': self.value}
this_dict.update(self.attributes())
return this_dict
def validate(self, name, value):
validator = self.attribute_validators.get(name, None)
if validator:
return True if validator(value) else False
return True
def __setattr__(self, name, value):
if name in self.attribute_names or name in ("name", "value"):
if name == 'name' and not value:
raise InvalidCookieError(message="Cookies must have names")
if value is not None:
if not self.validate(name, value):
raise InvalidCookieAttributeError(
name, value, "did not validate with " +
repr(self.attribute_validators.get(name)))
object.__setattr__(self, name, value)
def __getattr__(self, name):
if name in self.attribute_names:
return None
raise AttributeError(name)
def attributes(self):
dictionary = {}
for python_attr_name, cookie_attr_name in self.attribute_names.items():
value = getattr(self, python_attr_name)
renderer = self.attribute_renderers.get(python_attr_name, None)
if renderer:
value = renderer(value)
if not value:
continue
dictionary[cookie_attr_name] = value
return dictionary
def render_request(self):
name, value = self.name, self.value
renderer = self.attribute_renderers.get('name', None)
if renderer:
name = renderer(name)
renderer = self.attribute_renderers.get('value', None)
if renderer:
value = renderer(value)
return ''.join((name, "=", value))
def render_response(self):
name, value = self.name, self.value
renderer = self.attribute_renderers.get('name', None)
if renderer:
name = renderer(name)
renderer = self.attribute_renderers.get('value', None)
if renderer:
value = renderer(value)
return '; '.join(
['{0}={1}'.format(name, value)] +
[key if isinstance(val, bool) else '='.join((key, val))
for key, val in self.attributes().items()]
)
def __eq__(self, other):
attrs = ['name', 'value'] + list(self.attribute_names.keys())
for attr in attrs:
mine = getattr(self, attr, None)
his = getattr(other, attr, None)
if isinstance(mine, bytes):
mine = mine.decode('utf-8')
if isinstance(his, bytes):
his = his.decode('utf-8')
if attr == 'domain':
if mine and mine[0] == '.':
mine = mine[1:]
if his and his[0] == '.':
his = his[1:]
if mine != his:
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
attribute_names = {
'expires': 'Expires',
'max_age': 'Max-Age',
'domain': 'Domain',
'path': 'Path',
'comment': 'Comment',
'version': 'Version',
'secure': 'Secure',
'httponly': 'HttpOnly',
}
attribute_renderers = {
'value': encode_cookie_value,
'domain': render_domain,
'expires': render_date,
'max_age': lambda item: str(item) if item is not None else None,
'secure': lambda item: True if item else False,
'httponly': lambda item: True if item else False,
'comment': encode_extension_av,
'version': lambda item: (str(item) if isinstance(item, int)
else encode_extension_av(item)),
}
attribute_parsers = {
'value': parse_value,
'expires': parse_date,
'domain': parse_domain,
'path': parse_path,
'max_age': lambda item: long(strip_spaces_and_quotes(item)),
'comment': parse_string,
'version': lambda item: int(strip_spaces_and_quotes(item)),
'secure': lambda item: True if item else False,
'httponly': lambda item: True if item else False,
}
attribute_validators = {
'name': valid_name,
'value': valid_value,
'expires': valid_date,
'domain': valid_domain,
'path': valid_path,
'max_age': valid_max_age,
'comment': valid_value,
'version': lambda number: re.match("^\d+\Z", str(number)),
'secure': lambda item: item is True or item is False,
'httponly': lambda item: item is True or item is False,
}
class Cookies(dict):
DEFAULT_COOKIE_CLASS = Cookie
def __init__(self, *args, **kwargs):
dict.__init__(self)
self.all_cookies = []
self.cookie_class = kwargs.get(
"_cookie_class", self.DEFAULT_COOKIE_CLASS)
self.add(*args, **kwargs)
def add(self, *args, **kwargs):
for cookie in args:
self.all_cookies.append(cookie)
if cookie.name in self:
continue
self[cookie.name] = cookie
for key, value in kwargs.items():
cookie = self.cookie_class(key, value)
self.all_cookies.append(cookie)
if key in self:
continue
self[key] = cookie
def get_all(self, key):
return [cookie for cookie in self.all_cookies
if cookie.name == key]
def parse_request(self, header_data, ignore_bad_cookies=False):
cookies_dict = _parse_request(
header_data, ignore_bad_cookies=ignore_bad_cookies)
cookie_objects = []
for name, values in cookies_dict.items():
for value in values:
cookie_dict = {'name': name, 'value': value}
try:
cookie = self.cookie_class.from_dict(cookie_dict)
except InvalidCookieError:
if not ignore_bad_cookies:
raise
else:
cookie_objects.append(cookie)
try:
self.add(*cookie_objects)
except InvalidCookieError:
if not ignore_bad_cookies:
raise
_report_invalid_cookie(header_data)
return self
def parse_response(self, header_data, ignore_bad_cookies=False,
ignore_bad_attributes=True):
cookie_dicts = _parse_response(
header_data,
ignore_bad_cookies=ignore_bad_cookies,
ignore_bad_attributes=ignore_bad_attributes)
cookie_objects = []
for cookie_dict in cookie_dicts:
cookie = self.cookie_class.from_dict(cookie_dict)
cookie_objects.append(cookie)
self.add(*cookie_objects)
return self
@classmethod
def from_request(cls, header_data, ignore_bad_cookies=False):
"Construct a Cookies object from request header data."
cookies = cls()
cookies.parse_request(
header_data, ignore_bad_cookies=ignore_bad_cookies)
return cookies
@classmethod
def from_response(cls, header_data, ignore_bad_cookies=False,
ignore_bad_attributes=True):
"Construct a Cookies object from response header data."
cookies = cls()
cookies.parse_response(
header_data,
ignore_bad_cookies=ignore_bad_cookies,
ignore_bad_attributes=ignore_bad_attributes)
return cookies
def render_request(self, sort=True):
if not sort:
return ("; ".join(
cookie.render_request() for cookie in self.values()))
return ("; ".join(sorted(
cookie.render_request() for cookie in self.values())))
def render_response(self, sort=True):
rendered = [cookie.render_response() for cookie in self.values()]
return rendered if not sort else sorted(rendered)
def __repr__(self):
return "Cookies(%s)" % ', '.join("%s=%r" % (name, cookie.value) for
(name, cookie) in self.items())
def __eq__(self, other):
if not hasattr(other, "keys"):
return False
try:
keys = sorted(set(self.keys()) | set(other.keys()))
for key in keys:
if not key in self:
return False
if not key in other:
return False
if self[key] != other[key]:
return False
except (TypeError, KeyError):
raise
return True
def __ne__(self, other):
return not self.__eq__(other)