From 2eb9d9dc7ced56deea11dd2180ba23c4e74a404a Mon Sep 17 00:00:00 2001 From: Labrys of Knossos Date: Sat, 15 Dec 2018 00:55:30 -0500 Subject: [PATCH] Update guessit to 3.0.3 Also updates: - babelfish-0.5.5 - python-dateutil-2.7.5 - rebulk-1.0.0 - six-1.12.0 --- libs/bin/guessit.exe | Bin 0 -> 93036 bytes libs/dateutil/__init__.py | 15 +- libs/dateutil/_common.py | 43 + libs/dateutil/_version.py | 4 + libs/dateutil/easter.py | 51 +- libs/dateutil/parser.py | 886 ----- libs/dateutil/parser/__init__.py | 60 + libs/dateutil/parser/_parser.py | 1578 ++++++++ libs/dateutil/parser/isoparser.py | 406 +++ libs/dateutil/relativedelta.py | 532 ++- libs/dateutil/rrule.py | 1143 ++++-- libs/dateutil/tz.py | 951 ----- libs/dateutil/tz/__init__.py | 17 + libs/dateutil/tz/_common.py | 415 +++ libs/dateutil/tz/_factories.py | 49 + libs/dateutil/tz/tz.py | 1785 +++++++++ libs/dateutil/tz/win.py | 331 ++ libs/dateutil/tzwin.py | 182 +- libs/dateutil/utils.py | 71 + libs/dateutil/zoneinfo/__init__.py | 224 +- .../zoneinfo/dateutil-zoneinfo.tar.gz | Bin 0 -> 154226 bytes libs/dateutil/zoneinfo/rebuild.py | 53 + libs/dateutil/zoneinfo/zoneinfo-2010g.tar.gz | Bin 171995 -> 0 bytes libs/guessit/__init__.py | 6 + libs/guessit/__main__.py | 83 +- libs/guessit/__version__.py | 2 +- libs/guessit/api.py | 152 +- libs/guessit/config/options.json | 362 ++ libs/guessit/jsonutils.py | 22 +- libs/guessit/monkeypatch.py | 34 + libs/guessit/options.py | 245 +- libs/guessit/rules/__init__.py | 65 +- libs/guessit/rules/common/__init__.py | 1 + libs/guessit/rules/common/comparators.py | 35 +- libs/guessit/rules/common/date.py | 70 +- libs/guessit/rules/common/expected.py | 53 + libs/guessit/rules/common/formatters.py | 2 +- libs/guessit/rules/common/pattern.py | 27 + libs/guessit/rules/common/quantity.py | 106 + libs/guessit/rules/common/words.py | 45 +- libs/guessit/rules/markers/groups.py | 9 +- libs/guessit/rules/markers/path.py | 6 +- libs/guessit/rules/processors.py | 83 +- libs/guessit/rules/properties/audio_codec.py | 136 +- libs/guessit/rules/properties/bit_rate.py | 72 + libs/guessit/rules/properties/bonus.py | 13 +- libs/guessit/rules/properties/cds.py | 10 +- libs/guessit/rules/properties/container.py | 37 +- libs/guessit/rules/properties/country.py | 89 +- libs/guessit/rules/properties/crc.py | 11 +- libs/guessit/rules/properties/date.py | 18 +- libs/guessit/rules/properties/edition.py | 35 +- .../guessit/rules/properties/episode_title.py | 172 +- libs/guessit/rules/properties/episodes.py | 549 ++- libs/guessit/rules/properties/film.py | 16 +- libs/guessit/rules/properties/format.py | 67 - libs/guessit/rules/properties/language.py | 436 ++- libs/guessit/rules/properties/mimetype.py | 11 +- libs/guessit/rules/properties/other.py | 231 +- libs/guessit/rules/properties/part.py | 11 +- .../guessit/rules/properties/release_group.py | 308 +- libs/guessit/rules/properties/screen_size.py | 164 +- libs/guessit/rules/properties/size.py | 30 + libs/guessit/rules/properties/source.py | 201 ++ .../rules/properties/streaming_service.py | 198 + libs/guessit/rules/properties/title.py | 87 +- libs/guessit/rules/properties/type.py | 14 +- libs/guessit/rules/properties/video_codec.py | 90 +- libs/guessit/rules/properties/website.py | 63 +- libs/guessit/test/config/dummy.txt | 1 + libs/guessit/test/config/test.json | 4 + libs/guessit/test/config/test.yaml | 4 + libs/guessit/test/config/test.yml | 4 + .../test/enable_disable_properties.yml | 335 ++ libs/guessit/test/episodes.yml | 3185 +++++++++++++++-- libs/guessit/test/movies.yml | 1328 ++++++- libs/guessit/test/rules/audio_codec.yml | 81 +- libs/guessit/test/rules/cds.yml | 2 +- libs/guessit/test/rules/country.yml | 3 + libs/guessit/test/rules/edition.yml | 48 +- libs/guessit/test/rules/episodes.yml | 106 +- libs/guessit/test/rules/format.yml | 112 - libs/guessit/test/rules/language.yml | 10 +- libs/guessit/test/rules/other.yml | 98 +- libs/guessit/test/rules/processors_test.py | 46 + libs/guessit/test/rules/release_group.yml | 30 + libs/guessit/test/rules/screen_size.yml | 253 +- libs/guessit/test/rules/size.yml | 8 + libs/guessit/test/rules/source.yml | 323 ++ libs/guessit/test/rules/title.yml | 11 + libs/guessit/test/rules/video_codec.yml | 62 +- libs/guessit/test/streaming_services.yaml | 1934 ++++++++++ libs/guessit/test/test_api.py | 8 + .../guessit/test/test_api_unicode_literals.py | 8 + libs/guessit/test/test_options.py | 175 + libs/guessit/test/test_yml.py | 57 +- libs/guessit/test/various.yml | 422 ++- libs/guessit/yamlutils.py | 12 +- libs/rebulk/__version__.py | 2 +- libs/rebulk/chain.py | 47 +- libs/rebulk/loose.py | 46 +- libs/rebulk/match.py | 184 +- libs/rebulk/pattern.py | 52 +- libs/rebulk/processors.py | 3 +- libs/rebulk/rebulk.py | 13 + libs/rebulk/rules.py | 18 +- libs/rebulk/test/default_rules_module.py | 2 +- libs/rebulk/test/rebulk_rules_module.py | 2 +- libs/rebulk/test/rules_module.py | 2 +- libs/rebulk/test/test_chain.py | 110 +- libs/rebulk/test/test_debug.py | 2 +- libs/rebulk/test/test_introspector.py | 2 +- libs/rebulk/test/test_loose.py | 2 +- libs/rebulk/test/test_match.py | 9 +- libs/rebulk/test/test_pattern.py | 24 +- libs/rebulk/test/test_processors.py | 2 +- libs/rebulk/test/test_rebulk.py | 2 +- libs/rebulk/test/test_rules.py | 2 +- libs/rebulk/test/test_validators.py | 2 +- libs/rebulk/utils.py | 23 +- 120 files changed, 17964 insertions(+), 4530 deletions(-) create mode 100644 libs/bin/guessit.exe create mode 100644 libs/dateutil/_common.py create mode 100644 libs/dateutil/_version.py delete mode 100644 libs/dateutil/parser.py create mode 100644 libs/dateutil/parser/__init__.py create mode 100644 libs/dateutil/parser/_parser.py create mode 100644 libs/dateutil/parser/isoparser.py delete mode 100644 libs/dateutil/tz.py create mode 100644 libs/dateutil/tz/__init__.py create mode 100644 libs/dateutil/tz/_common.py create mode 100644 libs/dateutil/tz/_factories.py create mode 100644 libs/dateutil/tz/tz.py create mode 100644 libs/dateutil/tz/win.py create mode 100644 libs/dateutil/utils.py create mode 100644 libs/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz create mode 100644 libs/dateutil/zoneinfo/rebuild.py delete mode 100644 libs/dateutil/zoneinfo/zoneinfo-2010g.tar.gz create mode 100644 libs/guessit/config/options.json create mode 100644 libs/guessit/monkeypatch.py create mode 100644 libs/guessit/rules/common/expected.py create mode 100644 libs/guessit/rules/common/pattern.py create mode 100644 libs/guessit/rules/common/quantity.py create mode 100644 libs/guessit/rules/properties/bit_rate.py delete mode 100644 libs/guessit/rules/properties/format.py create mode 100644 libs/guessit/rules/properties/size.py create mode 100644 libs/guessit/rules/properties/source.py create mode 100644 libs/guessit/rules/properties/streaming_service.py create mode 100644 libs/guessit/test/config/dummy.txt create mode 100644 libs/guessit/test/config/test.json create mode 100644 libs/guessit/test/config/test.yaml create mode 100644 libs/guessit/test/config/test.yml create mode 100644 libs/guessit/test/enable_disable_properties.yml delete mode 100644 libs/guessit/test/rules/format.yml create mode 100644 libs/guessit/test/rules/processors_test.py create mode 100644 libs/guessit/test/rules/size.yml create mode 100644 libs/guessit/test/rules/source.yml create mode 100644 libs/guessit/test/streaming_services.yaml create mode 100644 libs/guessit/test/test_options.py diff --git a/libs/bin/guessit.exe b/libs/bin/guessit.exe new file mode 100644 index 0000000000000000000000000000000000000000..b50c3e4d518c685d57919d1ae8a71cb4320d7361 GIT binary patch literal 93036 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 H%wYQ$DR(4{ literal 0 HcmV?d00001 diff --git a/libs/dateutil/__init__.py b/libs/dateutil/__init__.py index 290814cf..0defb82e 100644 --- a/libs/dateutil/__init__.py +++ b/libs/dateutil/__init__.py @@ -1,9 +1,8 @@ -""" -Copyright (c) 2003-2010 Gustavo Niemeyer +# -*- coding: utf-8 -*- +try: + from ._version import version as __version__ +except ImportError: + __version__ = 'unknown' -This module offers extensions to the standard python 2.3+ -datetime module. -""" -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" -__version__ = "1.5" +__all__ = ['easter', 'parser', 'relativedelta', 'rrule', 'tz', + 'utils', 'zoneinfo'] diff --git a/libs/dateutil/_common.py b/libs/dateutil/_common.py new file mode 100644 index 00000000..4eb2659b --- /dev/null +++ b/libs/dateutil/_common.py @@ -0,0 +1,43 @@ +""" +Common code used in multiple modules. +""" + + +class weekday(object): + __slots__ = ["weekday", "n"] + + def __init__(self, weekday, n=None): + self.weekday = weekday + self.n = n + + def __call__(self, n): + if n == self.n: + return self + else: + return self.__class__(self.weekday, n) + + def __eq__(self, other): + try: + if self.weekday != other.weekday or self.n != other.n: + return False + except AttributeError: + return False + return True + + def __hash__(self): + return hash(( + self.weekday, + self.n, + )) + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + s = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[self.weekday] + if not self.n: + return s + else: + return "%s(%+d)" % (s, self.n) + +# vim:ts=4:sw=4:et diff --git a/libs/dateutil/_version.py b/libs/dateutil/_version.py new file mode 100644 index 00000000..d3ce8561 --- /dev/null +++ b/libs/dateutil/_version.py @@ -0,0 +1,4 @@ +# coding: utf-8 +# file generated by setuptools_scm +# don't change, don't track in version control +version = '2.7.5' diff --git a/libs/dateutil/easter.py b/libs/dateutil/easter.py index d7944104..53b7c789 100644 --- a/libs/dateutil/easter.py +++ b/libs/dateutil/easter.py @@ -1,19 +1,17 @@ +# -*- coding: utf-8 -*- """ -Copyright (c) 2003-2007 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. +This module offers a generic easter computing method for any given year, using +Western, Orthodox or Julian algorithms. """ -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" import datetime __all__ = ["easter", "EASTER_JULIAN", "EASTER_ORTHODOX", "EASTER_WESTERN"] -EASTER_JULIAN = 1 +EASTER_JULIAN = 1 EASTER_ORTHODOX = 2 -EASTER_WESTERN = 3 +EASTER_WESTERN = 3 + def easter(year, method=EASTER_WESTERN): """ @@ -25,7 +23,7 @@ def easter(year, method=EASTER_WESTERN): This algorithm implements three different easter calculation methods: - + 1 - Original calculation in Julian calendar, valid in dates after 326 AD 2 - Original method, with date converted to Gregorian @@ -35,24 +33,24 @@ def easter(year, method=EASTER_WESTERN): These methods are represented by the constants: - EASTER_JULIAN = 1 - EASTER_ORTHODOX = 2 - EASTER_WESTERN = 3 + * ``EASTER_JULIAN = 1`` + * ``EASTER_ORTHODOX = 2`` + * ``EASTER_WESTERN = 3`` The default method is method 3. - + More about the algorithm may be found at: - http://users.chariot.net.au/~gmarts/eastalg.htm + `GM Arts: Easter Algorithms `_ and - http://www.tondering.dk/claus/calendar.html + `The Calendar FAQ: Easter `_ """ if not (1 <= method <= 3): - raise ValueError, "invalid method" + raise ValueError("invalid method") # g - Golden year - 1 # c - Century @@ -69,24 +67,23 @@ def easter(year, method=EASTER_WESTERN): e = 0 if method < 3: # Old method - i = (19*g+15)%30 - j = (y+y//4+i)%7 + i = (19*g + 15) % 30 + j = (y + y//4 + i) % 7 if method == 2: # Extra dates to convert Julian to Gregorian date e = 10 if y > 1600: - e = e+y//100-16-(y//100-16)//4 + e = e + y//100 - 16 - (y//100 - 16)//4 else: # New method c = y//100 - h = (c-c//4-(8*c+13)//25+19*g+15)%30 - i = h-(h//28)*(1-(h//28)*(29//(h+1))*((21-g)//11)) - j = (y+y//4+i+2-c+c//4)%7 + h = (c - c//4 - (8*c + 13)//25 + 19*g + 15) % 30 + i = h - (h//28)*(1 - (h//28)*(29//(h + 1))*((21 - g)//11)) + j = (y + y//4 + i + 2 - c + c//4) % 7 # p can be from -6 to 56 corresponding to dates 22 March to 23 May # (later dates apply to method 2, although 23 May never actually occurs) - p = i-j+e - d = 1+(p+27+(p+6)//40)%31 - m = 3+(p+26)//30 - return datetime.date(int(y),int(m),int(d)) - + p = i - j + e + d = 1 + (p + 27 + (p + 6)//40) % 31 + m = 3 + (p + 26)//30 + return datetime.date(int(y), int(m), int(d)) diff --git a/libs/dateutil/parser.py b/libs/dateutil/parser.py deleted file mode 100644 index 5d824e41..00000000 --- a/libs/dateutil/parser.py +++ /dev/null @@ -1,886 +0,0 @@ -# -*- coding:iso-8859-1 -*- -""" -Copyright (c) 2003-2007 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. -""" -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" - -import datetime -import string -import time -import sys -import os - -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - -import relativedelta -import tz - - -__all__ = ["parse", "parserinfo"] - - -# Some pointers: -# -# http://www.cl.cam.ac.uk/~mgk25/iso-time.html -# http://www.iso.ch/iso/en/prods-services/popstds/datesandtime.html -# http://www.w3.org/TR/NOTE-datetime -# http://ringmaster.arc.nasa.gov/tools/time_formats.html -# http://search.cpan.org/author/MUIR/Time-modules-2003.0211/lib/Time/ParseDate.pm -# http://stein.cshl.org/jade/distrib/docs/java.text.SimpleDateFormat.html - - -class _timelex(object): - - def __init__(self, instream): - if isinstance(instream, basestring): - instream = StringIO(instream) - self.instream = instream - self.wordchars = ('abcdfeghijklmnopqrstuvwxyz' - 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' - 'ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ' - 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ') - self.numchars = '0123456789' - self.whitespace = ' \t\r\n' - self.charstack = [] - self.tokenstack = [] - self.eof = False - - def get_token(self): - if self.tokenstack: - return self.tokenstack.pop(0) - seenletters = False - token = None - state = None - wordchars = self.wordchars - numchars = self.numchars - whitespace = self.whitespace - while not self.eof: - if self.charstack: - nextchar = self.charstack.pop(0) - else: - nextchar = self.instream.read(1) - while nextchar == '\x00': - nextchar = self.instream.read(1) - if not nextchar: - self.eof = True - break - elif not state: - token = nextchar - if nextchar in wordchars: - state = 'a' - elif nextchar in numchars: - state = '0' - elif nextchar in whitespace: - token = ' ' - break # emit token - else: - break # emit token - elif state == 'a': - seenletters = True - if nextchar in wordchars: - token += nextchar - elif nextchar == '.': - token += nextchar - state = 'a.' - else: - self.charstack.append(nextchar) - break # emit token - elif state == '0': - if nextchar in numchars: - token += nextchar - elif nextchar == '.': - token += nextchar - state = '0.' - else: - self.charstack.append(nextchar) - break # emit token - elif state == 'a.': - seenletters = True - if nextchar == '.' or nextchar in wordchars: - token += nextchar - elif nextchar in numchars and token[-1] == '.': - token += nextchar - state = '0.' - else: - self.charstack.append(nextchar) - break # emit token - elif state == '0.': - if nextchar == '.' or nextchar in numchars: - token += nextchar - elif nextchar in wordchars and token[-1] == '.': - token += nextchar - state = 'a.' - else: - self.charstack.append(nextchar) - break # emit token - if (state in ('a.', '0.') and - (seenletters or token.count('.') > 1 or token[-1] == '.')): - l = token.split('.') - token = l[0] - for tok in l[1:]: - self.tokenstack.append('.') - if tok: - self.tokenstack.append(tok) - return token - - def __iter__(self): - return self - - def next(self): - token = self.get_token() - if token is None: - raise StopIteration - return token - - def split(cls, s): - return list(cls(s)) - split = classmethod(split) - - -class _resultbase(object): - - def __init__(self): - for attr in self.__slots__: - setattr(self, attr, None) - - def _repr(self, classname): - l = [] - for attr in self.__slots__: - value = getattr(self, attr) - if value is not None: - l.append("%s=%s" % (attr, `value`)) - return "%s(%s)" % (classname, ", ".join(l)) - - def __repr__(self): - return self._repr(self.__class__.__name__) - - -class parserinfo(object): - - # m from a.m/p.m, t from ISO T separator - JUMP = [" ", ".", ",", ";", "-", "/", "'", - "at", "on", "and", "ad", "m", "t", "of", - "st", "nd", "rd", "th"] - - WEEKDAYS = [("Mon", "Monday"), - ("Tue", "Tuesday"), - ("Wed", "Wednesday"), - ("Thu", "Thursday"), - ("Fri", "Friday"), - ("Sat", "Saturday"), - ("Sun", "Sunday")] - MONTHS = [("Jan", "January"), - ("Feb", "February"), - ("Mar", "March"), - ("Apr", "April"), - ("May", "May"), - ("Jun", "June"), - ("Jul", "July"), - ("Aug", "August"), - ("Sep", "September"), - ("Oct", "October"), - ("Nov", "November"), - ("Dec", "December")] - HMS = [("h", "hour", "hours"), - ("m", "minute", "minutes"), - ("s", "second", "seconds")] - AMPM = [("am", "a"), - ("pm", "p")] - UTCZONE = ["UTC", "GMT", "Z"] - PERTAIN = ["of"] - TZOFFSET = {} - - def __init__(self, dayfirst=False, yearfirst=False): - self._jump = self._convert(self.JUMP) - self._weekdays = self._convert(self.WEEKDAYS) - self._months = self._convert(self.MONTHS) - self._hms = self._convert(self.HMS) - self._ampm = self._convert(self.AMPM) - self._utczone = self._convert(self.UTCZONE) - self._pertain = self._convert(self.PERTAIN) - - self.dayfirst = dayfirst - self.yearfirst = yearfirst - - self._year = time.localtime().tm_year - self._century = self._year//100*100 - - def _convert(self, lst): - dct = {} - for i in range(len(lst)): - v = lst[i] - if isinstance(v, tuple): - for v in v: - dct[v.lower()] = i - else: - dct[v.lower()] = i - return dct - - def jump(self, name): - return name.lower() in self._jump - - def weekday(self, name): - if len(name) >= 3: - try: - return self._weekdays[name.lower()] - except KeyError: - pass - return None - - def month(self, name): - if len(name) >= 3: - try: - return self._months[name.lower()]+1 - except KeyError: - pass - return None - - def hms(self, name): - try: - return self._hms[name.lower()] - except KeyError: - return None - - def ampm(self, name): - try: - return self._ampm[name.lower()] - except KeyError: - return None - - def pertain(self, name): - return name.lower() in self._pertain - - def utczone(self, name): - return name.lower() in self._utczone - - def tzoffset(self, name): - if name in self._utczone: - return 0 - return self.TZOFFSET.get(name) - - def convertyear(self, year): - if year < 100: - year += self._century - if abs(year-self._year) >= 50: - if year < self._year: - year += 100 - else: - year -= 100 - return year - - def validate(self, res): - # move to info - if res.year is not None: - res.year = self.convertyear(res.year) - if res.tzoffset == 0 and not res.tzname or res.tzname == 'Z': - res.tzname = "UTC" - res.tzoffset = 0 - elif res.tzoffset != 0 and res.tzname and self.utczone(res.tzname): - res.tzoffset = 0 - return True - - -class parser(object): - - def __init__(self, info=None): - self.info = info or parserinfo() - - def parse(self, timestr, default=None, - ignoretz=False, tzinfos=None, - **kwargs): - if not default: - default = datetime.datetime.now().replace(hour=0, minute=0, - second=0, microsecond=0) - res = self._parse(timestr, **kwargs) - if res is None: - raise ValueError, "unknown string format" - repl = {} - for attr in ["year", "month", "day", "hour", - "minute", "second", "microsecond"]: - value = getattr(res, attr) - if value is not None: - repl[attr] = value - ret = default.replace(**repl) - if res.weekday is not None and not res.day: - ret = ret+relativedelta.relativedelta(weekday=res.weekday) - if not ignoretz: - if callable(tzinfos) or tzinfos and res.tzname in tzinfos: - if callable(tzinfos): - tzdata = tzinfos(res.tzname, res.tzoffset) - else: - tzdata = tzinfos.get(res.tzname) - if isinstance(tzdata, datetime.tzinfo): - tzinfo = tzdata - elif isinstance(tzdata, basestring): - tzinfo = tz.tzstr(tzdata) - elif isinstance(tzdata, int): - tzinfo = tz.tzoffset(res.tzname, tzdata) - else: - raise ValueError, "offset must be tzinfo subclass, " \ - "tz string, or int offset" - ret = ret.replace(tzinfo=tzinfo) - elif res.tzname and res.tzname in time.tzname: - ret = ret.replace(tzinfo=tz.tzlocal()) - elif res.tzoffset == 0: - ret = ret.replace(tzinfo=tz.tzutc()) - elif res.tzoffset: - ret = ret.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset)) - return ret - - class _result(_resultbase): - __slots__ = ["year", "month", "day", "weekday", - "hour", "minute", "second", "microsecond", - "tzname", "tzoffset"] - - def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False): - info = self.info - if dayfirst is None: - dayfirst = info.dayfirst - if yearfirst is None: - yearfirst = info.yearfirst - res = self._result() - l = _timelex.split(timestr) - try: - - # year/month/day list - ymd = [] - - # Index of the month string in ymd - mstridx = -1 - - len_l = len(l) - i = 0 - while i < len_l: - - # Check if it's a number - try: - value_repr = l[i] - value = float(value_repr) - except ValueError: - value = None - - if value is not None: - # Token is a number - len_li = len(l[i]) - i += 1 - if (len(ymd) == 3 and len_li in (2, 4) - and (i >= len_l or (l[i] != ':' and - info.hms(l[i]) is None))): - # 19990101T23[59] - s = l[i-1] - res.hour = int(s[:2]) - if len_li == 4: - res.minute = int(s[2:]) - elif len_li == 6 or (len_li > 6 and l[i-1].find('.') == 6): - # YYMMDD or HHMMSS[.ss] - s = l[i-1] - if not ymd and l[i-1].find('.') == -1: - ymd.append(info.convertyear(int(s[:2]))) - ymd.append(int(s[2:4])) - ymd.append(int(s[4:])) - else: - # 19990101T235959[.59] - res.hour = int(s[:2]) - res.minute = int(s[2:4]) - res.second, res.microsecond = _parsems(s[4:]) - elif len_li == 8: - # YYYYMMDD - s = l[i-1] - ymd.append(int(s[:4])) - ymd.append(int(s[4:6])) - ymd.append(int(s[6:])) - elif len_li in (12, 14): - # YYYYMMDDhhmm[ss] - s = l[i-1] - ymd.append(int(s[:4])) - ymd.append(int(s[4:6])) - ymd.append(int(s[6:8])) - res.hour = int(s[8:10]) - res.minute = int(s[10:12]) - if len_li == 14: - res.second = int(s[12:]) - elif ((i < len_l and info.hms(l[i]) is not None) or - (i+1 < len_l and l[i] == ' ' and - info.hms(l[i+1]) is not None)): - # HH[ ]h or MM[ ]m or SS[.ss][ ]s - if l[i] == ' ': - i += 1 - idx = info.hms(l[i]) - while True: - if idx == 0: - res.hour = int(value) - if value%1: - res.minute = int(60*(value%1)) - elif idx == 1: - res.minute = int(value) - if value%1: - res.second = int(60*(value%1)) - elif idx == 2: - res.second, res.microsecond = \ - _parsems(value_repr) - i += 1 - if i >= len_l or idx == 2: - break - # 12h00 - try: - value_repr = l[i] - value = float(value_repr) - except ValueError: - break - else: - i += 1 - idx += 1 - if i < len_l: - newidx = info.hms(l[i]) - if newidx is not None: - idx = newidx - elif i+1 < len_l and l[i] == ':': - # HH:MM[:SS[.ss]] - res.hour = int(value) - i += 1 - value = float(l[i]) - res.minute = int(value) - if value%1: - res.second = int(60*(value%1)) - i += 1 - if i < len_l and l[i] == ':': - res.second, res.microsecond = _parsems(l[i+1]) - i += 2 - elif i < len_l and l[i] in ('-', '/', '.'): - sep = l[i] - ymd.append(int(value)) - i += 1 - if i < len_l and not info.jump(l[i]): - try: - # 01-01[-01] - ymd.append(int(l[i])) - except ValueError: - # 01-Jan[-01] - value = info.month(l[i]) - if value is not None: - ymd.append(value) - assert mstridx == -1 - mstridx = len(ymd)-1 - else: - return None - i += 1 - if i < len_l and l[i] == sep: - # We have three members - i += 1 - value = info.month(l[i]) - if value is not None: - ymd.append(value) - mstridx = len(ymd)-1 - assert mstridx == -1 - else: - ymd.append(int(l[i])) - i += 1 - elif i >= len_l or info.jump(l[i]): - if i+1 < len_l and info.ampm(l[i+1]) is not None: - # 12 am - res.hour = int(value) - if res.hour < 12 and info.ampm(l[i+1]) == 1: - res.hour += 12 - elif res.hour == 12 and info.ampm(l[i+1]) == 0: - res.hour = 0 - i += 1 - else: - # Year, month or day - ymd.append(int(value)) - i += 1 - elif info.ampm(l[i]) is not None: - # 12am - res.hour = int(value) - if res.hour < 12 and info.ampm(l[i]) == 1: - res.hour += 12 - elif res.hour == 12 and info.ampm(l[i]) == 0: - res.hour = 0 - i += 1 - elif not fuzzy: - return None - else: - i += 1 - continue - - # Check weekday - value = info.weekday(l[i]) - if value is not None: - res.weekday = value - i += 1 - continue - - # Check month name - value = info.month(l[i]) - if value is not None: - ymd.append(value) - assert mstridx == -1 - mstridx = len(ymd)-1 - i += 1 - if i < len_l: - if l[i] in ('-', '/'): - # Jan-01[-99] - sep = l[i] - i += 1 - ymd.append(int(l[i])) - i += 1 - if i < len_l and l[i] == sep: - # Jan-01-99 - i += 1 - ymd.append(int(l[i])) - i += 1 - elif (i+3 < len_l and l[i] == l[i+2] == ' ' - and info.pertain(l[i+1])): - # Jan of 01 - # In this case, 01 is clearly year - try: - value = int(l[i+3]) - except ValueError: - # Wrong guess - pass - else: - # Convert it here to become unambiguous - ymd.append(info.convertyear(value)) - i += 4 - continue - - # Check am/pm - value = info.ampm(l[i]) - if value is not None: - if value == 1 and res.hour < 12: - res.hour += 12 - elif value == 0 and res.hour == 12: - res.hour = 0 - i += 1 - continue - - # Check for a timezone name - if (res.hour is not None and len(l[i]) <= 5 and - res.tzname is None and res.tzoffset is None and - not [x for x in l[i] if x not in string.ascii_uppercase]): - res.tzname = l[i] - res.tzoffset = info.tzoffset(res.tzname) - i += 1 - - # Check for something like GMT+3, or BRST+3. Notice - # that it doesn't mean "I am 3 hours after GMT", but - # "my time +3 is GMT". If found, we reverse the - # logic so that timezone parsing code will get it - # right. - if i < len_l and l[i] in ('+', '-'): - l[i] = ('+', '-')[l[i] == '+'] - res.tzoffset = None - if info.utczone(res.tzname): - # With something like GMT+3, the timezone - # is *not* GMT. - res.tzname = None - - continue - - # Check for a numbered timezone - if res.hour is not None and l[i] in ('+', '-'): - signal = (-1,1)[l[i] == '+'] - i += 1 - len_li = len(l[i]) - if len_li == 4: - # -0300 - res.tzoffset = int(l[i][:2])*3600+int(l[i][2:])*60 - elif i+1 < len_l and l[i+1] == ':': - # -03:00 - res.tzoffset = int(l[i])*3600+int(l[i+2])*60 - i += 2 - elif len_li <= 2: - # -[0]3 - res.tzoffset = int(l[i][:2])*3600 - else: - return None - i += 1 - res.tzoffset *= signal - - # Look for a timezone name between parenthesis - if (i+3 < len_l and - info.jump(l[i]) and l[i+1] == '(' and l[i+3] == ')' and - 3 <= len(l[i+2]) <= 5 and - not [x for x in l[i+2] - if x not in string.ascii_uppercase]): - # -0300 (BRST) - res.tzname = l[i+2] - i += 4 - continue - - # Check jumps - if not (info.jump(l[i]) or fuzzy): - return None - - i += 1 - - # Process year/month/day - len_ymd = len(ymd) - if len_ymd > 3: - # More than three members!? - return None - elif len_ymd == 1 or (mstridx != -1 and len_ymd == 2): - # One member, or two members with a month string - if mstridx != -1: - res.month = ymd[mstridx] - del ymd[mstridx] - if len_ymd > 1 or mstridx == -1: - if ymd[0] > 31: - res.year = ymd[0] - else: - res.day = ymd[0] - elif len_ymd == 2: - # Two members with numbers - if ymd[0] > 31: - # 99-01 - res.year, res.month = ymd - elif ymd[1] > 31: - # 01-99 - res.month, res.year = ymd - elif dayfirst and ymd[1] <= 12: - # 13-01 - res.day, res.month = ymd - else: - # 01-13 - res.month, res.day = ymd - if len_ymd == 3: - # Three members - if mstridx == 0: - res.month, res.day, res.year = ymd - elif mstridx == 1: - if ymd[0] > 31 or (yearfirst and ymd[2] <= 31): - # 99-Jan-01 - res.year, res.month, res.day = ymd - else: - # 01-Jan-01 - # Give precendence to day-first, since - # two-digit years is usually hand-written. - res.day, res.month, res.year = ymd - elif mstridx == 2: - # WTF!? - if ymd[1] > 31: - # 01-99-Jan - res.day, res.year, res.month = ymd - else: - # 99-01-Jan - res.year, res.day, res.month = ymd - else: - if ymd[0] > 31 or \ - (yearfirst and ymd[1] <= 12 and ymd[2] <= 31): - # 99-01-01 - res.year, res.month, res.day = ymd - elif ymd[0] > 12 or (dayfirst and ymd[1] <= 12): - # 13-01-01 - res.day, res.month, res.year = ymd - else: - # 01-13-01 - res.month, res.day, res.year = ymd - - except (IndexError, ValueError, AssertionError): - return None - - if not info.validate(res): - return None - return res - -DEFAULTPARSER = parser() -def parse(timestr, parserinfo=None, **kwargs): - if parserinfo: - return parser(parserinfo).parse(timestr, **kwargs) - else: - return DEFAULTPARSER.parse(timestr, **kwargs) - - -class _tzparser(object): - - class _result(_resultbase): - - __slots__ = ["stdabbr", "stdoffset", "dstabbr", "dstoffset", - "start", "end"] - - class _attr(_resultbase): - __slots__ = ["month", "week", "weekday", - "yday", "jyday", "day", "time"] - - def __repr__(self): - return self._repr("") - - def __init__(self): - _resultbase.__init__(self) - self.start = self._attr() - self.end = self._attr() - - def parse(self, tzstr): - res = self._result() - l = _timelex.split(tzstr) - try: - - len_l = len(l) - - i = 0 - while i < len_l: - # BRST+3[BRDT[+2]] - j = i - while j < len_l and not [x for x in l[j] - if x in "0123456789:,-+"]: - j += 1 - if j != i: - if not res.stdabbr: - offattr = "stdoffset" - res.stdabbr = "".join(l[i:j]) - else: - offattr = "dstoffset" - res.dstabbr = "".join(l[i:j]) - i = j - if (i < len_l and - (l[i] in ('+', '-') or l[i][0] in "0123456789")): - if l[i] in ('+', '-'): - # Yes, that's right. See the TZ variable - # documentation. - signal = (1,-1)[l[i] == '+'] - i += 1 - else: - signal = -1 - len_li = len(l[i]) - if len_li == 4: - # -0300 - setattr(res, offattr, - (int(l[i][:2])*3600+int(l[i][2:])*60)*signal) - elif i+1 < len_l and l[i+1] == ':': - # -03:00 - setattr(res, offattr, - (int(l[i])*3600+int(l[i+2])*60)*signal) - i += 2 - elif len_li <= 2: - # -[0]3 - setattr(res, offattr, - int(l[i][:2])*3600*signal) - else: - return None - i += 1 - if res.dstabbr: - break - else: - break - - if i < len_l: - for j in range(i, len_l): - if l[j] == ';': l[j] = ',' - - assert l[i] == ',' - - i += 1 - - if i >= len_l: - pass - elif (8 <= l.count(',') <= 9 and - not [y for x in l[i:] if x != ',' - for y in x if y not in "0123456789"]): - # GMT0BST,3,0,30,3600,10,0,26,7200[,3600] - for x in (res.start, res.end): - x.month = int(l[i]) - i += 2 - if l[i] == '-': - value = int(l[i+1])*-1 - i += 1 - else: - value = int(l[i]) - i += 2 - if value: - x.week = value - x.weekday = (int(l[i])-1)%7 - else: - x.day = int(l[i]) - i += 2 - x.time = int(l[i]) - i += 2 - if i < len_l: - if l[i] in ('-','+'): - signal = (-1,1)[l[i] == "+"] - i += 1 - else: - signal = 1 - res.dstoffset = (res.stdoffset+int(l[i]))*signal - elif (l.count(',') == 2 and l[i:].count('/') <= 2 and - not [y for x in l[i:] if x not in (',','/','J','M', - '.','-',':') - for y in x if y not in "0123456789"]): - for x in (res.start, res.end): - if l[i] == 'J': - # non-leap year day (1 based) - i += 1 - x.jyday = int(l[i]) - elif l[i] == 'M': - # month[-.]week[-.]weekday - i += 1 - x.month = int(l[i]) - i += 1 - assert l[i] in ('-', '.') - i += 1 - x.week = int(l[i]) - if x.week == 5: - x.week = -1 - i += 1 - assert l[i] in ('-', '.') - i += 1 - x.weekday = (int(l[i])-1)%7 - else: - # year day (zero based) - x.yday = int(l[i])+1 - - i += 1 - - if i < len_l and l[i] == '/': - i += 1 - # start time - len_li = len(l[i]) - if len_li == 4: - # -0300 - x.time = (int(l[i][:2])*3600+int(l[i][2:])*60) - elif i+1 < len_l and l[i+1] == ':': - # -03:00 - x.time = int(l[i])*3600+int(l[i+2])*60 - i += 2 - if i+1 < len_l and l[i+1] == ':': - i += 2 - x.time += int(l[i]) - elif len_li <= 2: - # -[0]3 - x.time = (int(l[i][:2])*3600) - else: - return None - i += 1 - - assert i == len_l or l[i] == ',' - - i += 1 - - assert i >= len_l - - except (IndexError, ValueError, AssertionError): - return None - - return res - - -DEFAULTTZPARSER = _tzparser() -def _parsetz(tzstr): - return DEFAULTTZPARSER.parse(tzstr) - - -def _parsems(value): - """Parse a I[.F] seconds value into (seconds, microseconds).""" - if "." not in value: - return int(value), 0 - else: - i, f = value.split(".") - return int(i), int(f.ljust(6, "0")[:6]) - - -# vim:ts=4:sw=4:et diff --git a/libs/dateutil/parser/__init__.py b/libs/dateutil/parser/__init__.py new file mode 100644 index 00000000..216762c0 --- /dev/null +++ b/libs/dateutil/parser/__init__.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from ._parser import parse, parser, parserinfo +from ._parser import DEFAULTPARSER, DEFAULTTZPARSER +from ._parser import UnknownTimezoneWarning + +from ._parser import __doc__ + +from .isoparser import isoparser, isoparse + +__all__ = ['parse', 'parser', 'parserinfo', + 'isoparse', 'isoparser', + 'UnknownTimezoneWarning'] + + +### +# Deprecate portions of the private interface so that downstream code that +# is improperly relying on it is given *some* notice. + + +def __deprecated_private_func(f): + from functools import wraps + import warnings + + msg = ('{name} is a private function and may break without warning, ' + 'it will be moved and or renamed in future versions.') + msg = msg.format(name=f.__name__) + + @wraps(f) + def deprecated_func(*args, **kwargs): + warnings.warn(msg, DeprecationWarning) + return f(*args, **kwargs) + + return deprecated_func + +def __deprecate_private_class(c): + import warnings + + msg = ('{name} is a private class and may break without warning, ' + 'it will be moved and or renamed in future versions.') + msg = msg.format(name=c.__name__) + + class private_class(c): + __doc__ = c.__doc__ + + def __init__(self, *args, **kwargs): + warnings.warn(msg, DeprecationWarning) + super(private_class, self).__init__(*args, **kwargs) + + private_class.__name__ = c.__name__ + + return private_class + + +from ._parser import _timelex, _resultbase +from ._parser import _tzparser, _parsetz + +_timelex = __deprecate_private_class(_timelex) +_tzparser = __deprecate_private_class(_tzparser) +_resultbase = __deprecate_private_class(_resultbase) +_parsetz = __deprecated_private_func(_parsetz) diff --git a/libs/dateutil/parser/_parser.py b/libs/dateutil/parser/_parser.py new file mode 100644 index 00000000..9d2bb795 --- /dev/null +++ b/libs/dateutil/parser/_parser.py @@ -0,0 +1,1578 @@ +# -*- coding: utf-8 -*- +""" +This module offers a generic date/time string parser which is able to parse +most known formats to represent a date and/or time. + +This module attempts to be forgiving with regards to unlikely input formats, +returning a datetime object even for dates which are ambiguous. If an element +of a date/time stamp is omitted, the following rules are applied: + +- If AM or PM is left unspecified, a 24-hour clock is assumed, however, an hour + on a 12-hour clock (``0 <= hour <= 12``) *must* be specified if AM or PM is + specified. +- If a time zone is omitted, a timezone-naive datetime is returned. + +If any other elements are missing, they are taken from the +:class:`datetime.datetime` object passed to the parameter ``default``. If this +results in a day number exceeding the valid number of days per month, the +value falls back to the end of the month. + +Additional resources about date/time string formats can be found below: + +- `A summary of the international standard date and time notation + `_ +- `W3C Date and Time Formats `_ +- `Time Formats (Planetary Rings Node) `_ +- `CPAN ParseDate module + `_ +- `Java SimpleDateFormat Class + `_ +""" +from __future__ import unicode_literals + +import datetime +import re +import string +import time +import warnings + +from calendar import monthrange +from io import StringIO + +import six +from six import binary_type, integer_types, text_type + +from decimal import Decimal + +from warnings import warn + +from .. import relativedelta +from .. import tz + +__all__ = ["parse", "parserinfo"] + + +# TODO: pandas.core.tools.datetimes imports this explicitly. Might be worth +# making public and/or figuring out if there is something we can +# take off their plate. +class _timelex(object): + # Fractional seconds are sometimes split by a comma + _split_decimal = re.compile("([.,])") + + def __init__(self, instream): + if six.PY2: + # In Python 2, we can't duck type properly because unicode has + # a 'decode' function, and we'd be double-decoding + if isinstance(instream, (binary_type, bytearray)): + instream = instream.decode() + else: + if getattr(instream, 'decode', None) is not None: + instream = instream.decode() + + if isinstance(instream, text_type): + instream = StringIO(instream) + elif getattr(instream, 'read', None) is None: + raise TypeError('Parser must be a string or character stream, not ' + '{itype}'.format(itype=instream.__class__.__name__)) + + self.instream = instream + self.charstack = [] + self.tokenstack = [] + self.eof = False + + def get_token(self): + """ + This function breaks the time string into lexical units (tokens), which + can be parsed by the parser. Lexical units are demarcated by changes in + the character set, so any continuous string of letters is considered + one unit, any continuous string of numbers is considered one unit. + + The main complication arises from the fact that dots ('.') can be used + both as separators (e.g. "Sep.20.2009") or decimal points (e.g. + "4:30:21.447"). As such, it is necessary to read the full context of + any dot-separated strings before breaking it into tokens; as such, this + function maintains a "token stack", for when the ambiguous context + demands that multiple tokens be parsed at once. + """ + if self.tokenstack: + return self.tokenstack.pop(0) + + seenletters = False + token = None + state = None + + while not self.eof: + # We only realize that we've reached the end of a token when we + # find a character that's not part of the current token - since + # that character may be part of the next token, it's stored in the + # charstack. + if self.charstack: + nextchar = self.charstack.pop(0) + else: + nextchar = self.instream.read(1) + while nextchar == '\x00': + nextchar = self.instream.read(1) + + if not nextchar: + self.eof = True + break + elif not state: + # First character of the token - determines if we're starting + # to parse a word, a number or something else. + token = nextchar + if self.isword(nextchar): + state = 'a' + elif self.isnum(nextchar): + state = '0' + elif self.isspace(nextchar): + token = ' ' + break # emit token + else: + break # emit token + elif state == 'a': + # If we've already started reading a word, we keep reading + # letters until we find something that's not part of a word. + seenletters = True + if self.isword(nextchar): + token += nextchar + elif nextchar == '.': + token += nextchar + state = 'a.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == '0': + # If we've already started reading a number, we keep reading + # numbers until we find something that doesn't fit. + if self.isnum(nextchar): + token += nextchar + elif nextchar == '.' or (nextchar == ',' and len(token) >= 2): + token += nextchar + state = '0.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == 'a.': + # If we've seen some letters and a dot separator, continue + # parsing, and the tokens will be broken up later. + seenletters = True + if nextchar == '.' or self.isword(nextchar): + token += nextchar + elif self.isnum(nextchar) and token[-1] == '.': + token += nextchar + state = '0.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == '0.': + # If we've seen at least one dot separator, keep going, we'll + # break up the tokens later. + if nextchar == '.' or self.isnum(nextchar): + token += nextchar + elif self.isword(nextchar) and token[-1] == '.': + token += nextchar + state = 'a.' + else: + self.charstack.append(nextchar) + break # emit token + + if (state in ('a.', '0.') and (seenletters or token.count('.') > 1 or + token[-1] in '.,')): + l = self._split_decimal.split(token) + token = l[0] + for tok in l[1:]: + if tok: + self.tokenstack.append(tok) + + if state == '0.' and token.count('.') == 0: + token = token.replace(',', '.') + + return token + + def __iter__(self): + return self + + def __next__(self): + token = self.get_token() + if token is None: + raise StopIteration + + return token + + def next(self): + return self.__next__() # Python 2.x support + + @classmethod + def split(cls, s): + return list(cls(s)) + + @classmethod + def isword(cls, nextchar): + """ Whether or not the next character is part of a word """ + return nextchar.isalpha() + + @classmethod + def isnum(cls, nextchar): + """ Whether the next character is part of a number """ + return nextchar.isdigit() + + @classmethod + def isspace(cls, nextchar): + """ Whether the next character is whitespace """ + return nextchar.isspace() + + +class _resultbase(object): + + def __init__(self): + for attr in self.__slots__: + setattr(self, attr, None) + + def _repr(self, classname): + l = [] + for attr in self.__slots__: + value = getattr(self, attr) + if value is not None: + l.append("%s=%s" % (attr, repr(value))) + return "%s(%s)" % (classname, ", ".join(l)) + + def __len__(self): + return (sum(getattr(self, attr) is not None + for attr in self.__slots__)) + + def __repr__(self): + return self._repr(self.__class__.__name__) + + +class parserinfo(object): + """ + Class which handles what inputs are accepted. Subclass this to customize + the language and acceptable values for each parameter. + + :param dayfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the day (``True``) or month (``False``). If + ``yearfirst`` is set to ``True``, this distinguishes between YDM + and YMD. Default is ``False``. + + :param yearfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the year. If ``True``, the first number is taken + to be the year, otherwise the last number is taken to be the year. + Default is ``False``. + """ + + # m from a.m/p.m, t from ISO T separator + JUMP = [" ", ".", ",", ";", "-", "/", "'", + "at", "on", "and", "ad", "m", "t", "of", + "st", "nd", "rd", "th"] + + WEEKDAYS = [("Mon", "Monday"), + ("Tue", "Tuesday"), # TODO: "Tues" + ("Wed", "Wednesday"), + ("Thu", "Thursday"), # TODO: "Thurs" + ("Fri", "Friday"), + ("Sat", "Saturday"), + ("Sun", "Sunday")] + MONTHS = [("Jan", "January"), + ("Feb", "February"), # TODO: "Febr" + ("Mar", "March"), + ("Apr", "April"), + ("May", "May"), + ("Jun", "June"), + ("Jul", "July"), + ("Aug", "August"), + ("Sep", "Sept", "September"), + ("Oct", "October"), + ("Nov", "November"), + ("Dec", "December")] + HMS = [("h", "hour", "hours"), + ("m", "minute", "minutes"), + ("s", "second", "seconds")] + AMPM = [("am", "a"), + ("pm", "p")] + UTCZONE = ["UTC", "GMT", "Z"] + PERTAIN = ["of"] + TZOFFSET = {} + # TODO: ERA = ["AD", "BC", "CE", "BCE", "Stardate", + # "Anno Domini", "Year of Our Lord"] + + def __init__(self, dayfirst=False, yearfirst=False): + self._jump = self._convert(self.JUMP) + self._weekdays = self._convert(self.WEEKDAYS) + self._months = self._convert(self.MONTHS) + self._hms = self._convert(self.HMS) + self._ampm = self._convert(self.AMPM) + self._utczone = self._convert(self.UTCZONE) + self._pertain = self._convert(self.PERTAIN) + + self.dayfirst = dayfirst + self.yearfirst = yearfirst + + self._year = time.localtime().tm_year + self._century = self._year // 100 * 100 + + def _convert(self, lst): + dct = {} + for i, v in enumerate(lst): + if isinstance(v, tuple): + for v in v: + dct[v.lower()] = i + else: + dct[v.lower()] = i + return dct + + def jump(self, name): + return name.lower() in self._jump + + def weekday(self, name): + try: + return self._weekdays[name.lower()] + except KeyError: + pass + return None + + def month(self, name): + try: + return self._months[name.lower()] + 1 + except KeyError: + pass + return None + + def hms(self, name): + try: + return self._hms[name.lower()] + except KeyError: + return None + + def ampm(self, name): + try: + return self._ampm[name.lower()] + except KeyError: + return None + + def pertain(self, name): + return name.lower() in self._pertain + + def utczone(self, name): + return name.lower() in self._utczone + + def tzoffset(self, name): + if name in self._utczone: + return 0 + + return self.TZOFFSET.get(name) + + def convertyear(self, year, century_specified=False): + """ + Converts two-digit years to year within [-50, 49] + range of self._year (current local time) + """ + + # Function contract is that the year is always positive + assert year >= 0 + + if year < 100 and not century_specified: + # assume current century to start + year += self._century + + if year >= self._year + 50: # if too far in future + year -= 100 + elif year < self._year - 50: # if too far in past + year += 100 + + return year + + def validate(self, res): + # move to info + if res.year is not None: + res.year = self.convertyear(res.year, res.century_specified) + + if res.tzoffset == 0 and not res.tzname or res.tzname == 'Z': + res.tzname = "UTC" + res.tzoffset = 0 + elif res.tzoffset != 0 and res.tzname and self.utczone(res.tzname): + res.tzoffset = 0 + return True + + +class _ymd(list): + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + self.century_specified = False + self.dstridx = None + self.mstridx = None + self.ystridx = None + + @property + def has_year(self): + return self.ystridx is not None + + @property + def has_month(self): + return self.mstridx is not None + + @property + def has_day(self): + return self.dstridx is not None + + def could_be_day(self, value): + if self.has_day: + return False + elif not self.has_month: + return 1 <= value <= 31 + elif not self.has_year: + # Be permissive, assume leapyear + month = self[self.mstridx] + return 1 <= value <= monthrange(2000, month)[1] + else: + month = self[self.mstridx] + year = self[self.ystridx] + return 1 <= value <= monthrange(year, month)[1] + + def append(self, val, label=None): + if hasattr(val, '__len__'): + if val.isdigit() and len(val) > 2: + self.century_specified = True + if label not in [None, 'Y']: # pragma: no cover + raise ValueError(label) + label = 'Y' + elif val > 100: + self.century_specified = True + if label not in [None, 'Y']: # pragma: no cover + raise ValueError(label) + label = 'Y' + + super(self.__class__, self).append(int(val)) + + if label == 'M': + if self.has_month: + raise ValueError('Month is already set') + self.mstridx = len(self) - 1 + elif label == 'D': + if self.has_day: + raise ValueError('Day is already set') + self.dstridx = len(self) - 1 + elif label == 'Y': + if self.has_year: + raise ValueError('Year is already set') + self.ystridx = len(self) - 1 + + def _resolve_from_stridxs(self, strids): + """ + Try to resolve the identities of year/month/day elements using + ystridx, mstridx, and dstridx, if enough of these are specified. + """ + if len(self) == 3 and len(strids) == 2: + # we can back out the remaining stridx value + missing = [x for x in range(3) if x not in strids.values()] + key = [x for x in ['y', 'm', 'd'] if x not in strids] + assert len(missing) == len(key) == 1 + key = key[0] + val = missing[0] + strids[key] = val + + assert len(self) == len(strids) # otherwise this should not be called + out = {key: self[strids[key]] for key in strids} + return (out.get('y'), out.get('m'), out.get('d')) + + def resolve_ymd(self, yearfirst, dayfirst): + len_ymd = len(self) + year, month, day = (None, None, None) + + strids = (('y', self.ystridx), + ('m', self.mstridx), + ('d', self.dstridx)) + + strids = {key: val for key, val in strids if val is not None} + if (len(self) == len(strids) > 0 or + (len(self) == 3 and len(strids) == 2)): + return self._resolve_from_stridxs(strids) + + mstridx = self.mstridx + + if len_ymd > 3: + raise ValueError("More than three YMD values") + elif len_ymd == 1 or (mstridx is not None and len_ymd == 2): + # One member, or two members with a month string + if mstridx is not None: + month = self[mstridx] + # since mstridx is 0 or 1, self[mstridx-1] always + # looks up the other element + other = self[mstridx - 1] + else: + other = self[0] + + if len_ymd > 1 or mstridx is None: + if other > 31: + year = other + else: + day = other + + elif len_ymd == 2: + # Two members with numbers + if self[0] > 31: + # 99-01 + year, month = self + elif self[1] > 31: + # 01-99 + month, year = self + elif dayfirst and self[1] <= 12: + # 13-01 + day, month = self + else: + # 01-13 + month, day = self + + elif len_ymd == 3: + # Three members + if mstridx == 0: + if self[1] > 31: + # Apr-2003-25 + month, year, day = self + else: + month, day, year = self + elif mstridx == 1: + if self[0] > 31 or (yearfirst and self[2] <= 31): + # 99-Jan-01 + year, month, day = self + else: + # 01-Jan-01 + # Give precendence to day-first, since + # two-digit years is usually hand-written. + day, month, year = self + + elif mstridx == 2: + # WTF!? + if self[1] > 31: + # 01-99-Jan + day, year, month = self + else: + # 99-01-Jan + year, day, month = self + + else: + if (self[0] > 31 or + self.ystridx == 0 or + (yearfirst and self[1] <= 12 and self[2] <= 31)): + # 99-01-01 + if dayfirst and self[2] <= 12: + year, day, month = self + else: + year, month, day = self + elif self[0] > 12 or (dayfirst and self[1] <= 12): + # 13-01-01 + day, month, year = self + else: + # 01-13-01 + month, day, year = self + + return year, month, day + + +class parser(object): + def __init__(self, info=None): + self.info = info or parserinfo() + + def parse(self, timestr, default=None, + ignoretz=False, tzinfos=None, **kwargs): + """ + Parse the date/time string into a :class:`datetime.datetime` object. + + :param timestr: + Any date/time string using the supported formats. + + :param default: + The default datetime object, if this is a datetime object and not + ``None``, elements specified in ``timestr`` replace elements in the + default object. + + :param ignoretz: + If set ``True``, time zones in parsed strings are ignored and a + naive :class:`datetime.datetime` object is returned. + + :param tzinfos: + Additional time zone names / aliases which may be present in the + string. This argument maps time zone names (and optionally offsets + from those time zones) to time zones. This parameter can be a + dictionary with timezone aliases mapping time zone names to time + zones or a function taking two parameters (``tzname`` and + ``tzoffset``) and returning a time zone. + + The timezones to which the names are mapped can be an integer + offset from UTC in seconds or a :class:`tzinfo` object. + + .. doctest:: + :options: +NORMALIZE_WHITESPACE + + >>> from dateutil.parser import parse + >>> from dateutil.tz import gettz + >>> tzinfos = {"BRST": -7200, "CST": gettz("America/Chicago")} + >>> parse("2012-01-19 17:21:00 BRST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -7200)) + >>> parse("2012-01-19 17:21:00 CST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, + tzinfo=tzfile('/usr/share/zoneinfo/America/Chicago')) + + This parameter is ignored if ``ignoretz`` is set. + + :param \\*\\*kwargs: + Keyword arguments as passed to ``_parse()``. + + :return: + Returns a :class:`datetime.datetime` object or, if the + ``fuzzy_with_tokens`` option is ``True``, returns a tuple, the + first element being a :class:`datetime.datetime` object, the second + a tuple containing the fuzzy tokens. + + :raises ValueError: + Raised for invalid or unknown string format, if the provided + :class:`tzinfo` is not in a valid format, or if an invalid date + would be created. + + :raises TypeError: + Raised for non-string or character stream input. + + :raises OverflowError: + Raised if the parsed date exceeds the largest valid C integer on + your system. + """ + + if default is None: + default = datetime.datetime.now().replace(hour=0, minute=0, + second=0, microsecond=0) + + res, skipped_tokens = self._parse(timestr, **kwargs) + + if res is None: + raise ValueError("Unknown string format:", timestr) + + if len(res) == 0: + raise ValueError("String does not contain a date:", timestr) + + ret = self._build_naive(res, default) + + if not ignoretz: + ret = self._build_tzaware(ret, res, tzinfos) + + if kwargs.get('fuzzy_with_tokens', False): + return ret, skipped_tokens + else: + return ret + + class _result(_resultbase): + __slots__ = ["year", "month", "day", "weekday", + "hour", "minute", "second", "microsecond", + "tzname", "tzoffset", "ampm","any_unused_tokens"] + + def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False, + fuzzy_with_tokens=False): + """ + Private method which performs the heavy lifting of parsing, called from + ``parse()``, which passes on its ``kwargs`` to this function. + + :param timestr: + The string to parse. + + :param dayfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the day (``True``) or month (``False``). If + ``yearfirst`` is set to ``True``, this distinguishes between YDM + and YMD. If set to ``None``, this value is retrieved from the + current :class:`parserinfo` object (which itself defaults to + ``False``). + + :param yearfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the year. If ``True``, the first number is taken + to be the year, otherwise the last number is taken to be the year. + If this is set to ``None``, the value is retrieved from the current + :class:`parserinfo` object (which itself defaults to ``False``). + + :param fuzzy: + Whether to allow fuzzy parsing, allowing for string like "Today is + January 1, 2047 at 8:21:00AM". + + :param fuzzy_with_tokens: + If ``True``, ``fuzzy`` is automatically set to True, and the parser + will return a tuple where the first element is the parsed + :class:`datetime.datetime` datetimestamp and the second element is + a tuple containing the portions of the string which were ignored: + + .. doctest:: + + >>> from dateutil.parser import parse + >>> parse("Today is January 1, 2047 at 8:21:00AM", fuzzy_with_tokens=True) + (datetime.datetime(2047, 1, 1, 8, 21), (u'Today is ', u' ', u'at ')) + + """ + if fuzzy_with_tokens: + fuzzy = True + + info = self.info + + if dayfirst is None: + dayfirst = info.dayfirst + + if yearfirst is None: + yearfirst = info.yearfirst + + res = self._result() + l = _timelex.split(timestr) # Splits the timestr into tokens + + skipped_idxs = [] + + # year/month/day list + ymd = _ymd() + + len_l = len(l) + i = 0 + try: + while i < len_l: + + # Check if it's a number + value_repr = l[i] + try: + value = float(value_repr) + except ValueError: + value = None + + if value is not None: + # Numeric token + i = self._parse_numeric_token(l, i, info, ymd, res, fuzzy) + + # Check weekday + elif info.weekday(l[i]) is not None: + value = info.weekday(l[i]) + res.weekday = value + + # Check month name + elif info.month(l[i]) is not None: + value = info.month(l[i]) + ymd.append(value, 'M') + + if i + 1 < len_l: + if l[i + 1] in ('-', '/'): + # Jan-01[-99] + sep = l[i + 1] + ymd.append(l[i + 2]) + + if i + 3 < len_l and l[i + 3] == sep: + # Jan-01-99 + ymd.append(l[i + 4]) + i += 2 + + i += 2 + + elif (i + 4 < len_l and l[i + 1] == l[i + 3] == ' ' and + info.pertain(l[i + 2])): + # Jan of 01 + # In this case, 01 is clearly year + if l[i + 4].isdigit(): + # Convert it here to become unambiguous + value = int(l[i + 4]) + year = str(info.convertyear(value)) + ymd.append(year, 'Y') + else: + # Wrong guess + pass + # TODO: not hit in tests + i += 4 + + # Check am/pm + elif info.ampm(l[i]) is not None: + value = info.ampm(l[i]) + val_is_ampm = self._ampm_valid(res.hour, res.ampm, fuzzy) + + if val_is_ampm: + res.hour = self._adjust_ampm(res.hour, value) + res.ampm = value + + elif fuzzy: + skipped_idxs.append(i) + + # Check for a timezone name + elif self._could_be_tzname(res.hour, res.tzname, res.tzoffset, l[i]): + res.tzname = l[i] + res.tzoffset = info.tzoffset(res.tzname) + + # Check for something like GMT+3, or BRST+3. Notice + # that it doesn't mean "I am 3 hours after GMT", but + # "my time +3 is GMT". If found, we reverse the + # logic so that timezone parsing code will get it + # right. + if i + 1 < len_l and l[i + 1] in ('+', '-'): + l[i + 1] = ('+', '-')[l[i + 1] == '+'] + res.tzoffset = None + if info.utczone(res.tzname): + # With something like GMT+3, the timezone + # is *not* GMT. + res.tzname = None + + # Check for a numbered timezone + elif res.hour is not None and l[i] in ('+', '-'): + signal = (-1, 1)[l[i] == '+'] + len_li = len(l[i + 1]) + + # TODO: check that l[i + 1] is integer? + if len_li == 4: + # -0300 + hour_offset = int(l[i + 1][:2]) + min_offset = int(l[i + 1][2:]) + elif i + 2 < len_l and l[i + 2] == ':': + # -03:00 + hour_offset = int(l[i + 1]) + min_offset = int(l[i + 3]) # TODO: Check that l[i+3] is minute-like? + i += 2 + elif len_li <= 2: + # -[0]3 + hour_offset = int(l[i + 1][:2]) + min_offset = 0 + else: + raise ValueError(timestr) + + res.tzoffset = signal * (hour_offset * 3600 + min_offset * 60) + + # Look for a timezone name between parenthesis + if (i + 5 < len_l and + info.jump(l[i + 2]) and l[i + 3] == '(' and + l[i + 5] == ')' and + 3 <= len(l[i + 4]) and + self._could_be_tzname(res.hour, res.tzname, + None, l[i + 4])): + # -0300 (BRST) + res.tzname = l[i + 4] + i += 4 + + i += 1 + + # Check jumps + elif not (info.jump(l[i]) or fuzzy): + raise ValueError(timestr) + + else: + skipped_idxs.append(i) + i += 1 + + # Process year/month/day + year, month, day = ymd.resolve_ymd(yearfirst, dayfirst) + + res.century_specified = ymd.century_specified + res.year = year + res.month = month + res.day = day + + except (IndexError, ValueError): + return None, None + + if not info.validate(res): + return None, None + + if fuzzy_with_tokens: + skipped_tokens = self._recombine_skipped(l, skipped_idxs) + return res, tuple(skipped_tokens) + else: + return res, None + + def _parse_numeric_token(self, tokens, idx, info, ymd, res, fuzzy): + # Token is a number + value_repr = tokens[idx] + try: + value = self._to_decimal(value_repr) + except Exception as e: + six.raise_from(ValueError('Unknown numeric token'), e) + + len_li = len(value_repr) + + len_l = len(tokens) + + if (len(ymd) == 3 and len_li in (2, 4) and + res.hour is None and + (idx + 1 >= len_l or + (tokens[idx + 1] != ':' and + info.hms(tokens[idx + 1]) is None))): + # 19990101T23[59] + s = tokens[idx] + res.hour = int(s[:2]) + + if len_li == 4: + res.minute = int(s[2:]) + + elif len_li == 6 or (len_li > 6 and tokens[idx].find('.') == 6): + # YYMMDD or HHMMSS[.ss] + s = tokens[idx] + + if not ymd and '.' not in tokens[idx]: + ymd.append(s[:2]) + ymd.append(s[2:4]) + ymd.append(s[4:]) + else: + # 19990101T235959[.59] + + # TODO: Check if res attributes already set. + res.hour = int(s[:2]) + res.minute = int(s[2:4]) + res.second, res.microsecond = self._parsems(s[4:]) + + elif len_li in (8, 12, 14): + # YYYYMMDD + s = tokens[idx] + ymd.append(s[:4], 'Y') + ymd.append(s[4:6]) + ymd.append(s[6:8]) + + if len_li > 8: + res.hour = int(s[8:10]) + res.minute = int(s[10:12]) + + if len_li > 12: + res.second = int(s[12:]) + + elif self._find_hms_idx(idx, tokens, info, allow_jump=True) is not None: + # HH[ ]h or MM[ ]m or SS[.ss][ ]s + hms_idx = self._find_hms_idx(idx, tokens, info, allow_jump=True) + (idx, hms) = self._parse_hms(idx, tokens, info, hms_idx) + if hms is not None: + # TODO: checking that hour/minute/second are not + # already set? + self._assign_hms(res, value_repr, hms) + + elif idx + 2 < len_l and tokens[idx + 1] == ':': + # HH:MM[:SS[.ss]] + res.hour = int(value) + value = self._to_decimal(tokens[idx + 2]) # TODO: try/except for this? + (res.minute, res.second) = self._parse_min_sec(value) + + if idx + 4 < len_l and tokens[idx + 3] == ':': + res.second, res.microsecond = self._parsems(tokens[idx + 4]) + + idx += 2 + + idx += 2 + + elif idx + 1 < len_l and tokens[idx + 1] in ('-', '/', '.'): + sep = tokens[idx + 1] + ymd.append(value_repr) + + if idx + 2 < len_l and not info.jump(tokens[idx + 2]): + if tokens[idx + 2].isdigit(): + # 01-01[-01] + ymd.append(tokens[idx + 2]) + else: + # 01-Jan[-01] + value = info.month(tokens[idx + 2]) + + if value is not None: + ymd.append(value, 'M') + else: + raise ValueError() + + if idx + 3 < len_l and tokens[idx + 3] == sep: + # We have three members + value = info.month(tokens[idx + 4]) + + if value is not None: + ymd.append(value, 'M') + else: + ymd.append(tokens[idx + 4]) + idx += 2 + + idx += 1 + idx += 1 + + elif idx + 1 >= len_l or info.jump(tokens[idx + 1]): + if idx + 2 < len_l and info.ampm(tokens[idx + 2]) is not None: + # 12 am + hour = int(value) + res.hour = self._adjust_ampm(hour, info.ampm(tokens[idx + 2])) + idx += 1 + else: + # Year, month or day + ymd.append(value) + idx += 1 + + elif info.ampm(tokens[idx + 1]) is not None and (0 <= value < 24): + # 12am + hour = int(value) + res.hour = self._adjust_ampm(hour, info.ampm(tokens[idx + 1])) + idx += 1 + + elif ymd.could_be_day(value): + ymd.append(value) + + elif not fuzzy: + raise ValueError() + + return idx + + def _find_hms_idx(self, idx, tokens, info, allow_jump): + len_l = len(tokens) + + if idx+1 < len_l and info.hms(tokens[idx+1]) is not None: + # There is an "h", "m", or "s" label following this token. We take + # assign the upcoming label to the current token. + # e.g. the "12" in 12h" + hms_idx = idx + 1 + + elif (allow_jump and idx+2 < len_l and tokens[idx+1] == ' ' and + info.hms(tokens[idx+2]) is not None): + # There is a space and then an "h", "m", or "s" label. + # e.g. the "12" in "12 h" + hms_idx = idx + 2 + + elif idx > 0 and info.hms(tokens[idx-1]) is not None: + # There is a "h", "m", or "s" preceeding this token. Since neither + # of the previous cases was hit, there is no label following this + # token, so we use the previous label. + # e.g. the "04" in "12h04" + hms_idx = idx-1 + + elif (1 < idx == len_l-1 and tokens[idx-1] == ' ' and + info.hms(tokens[idx-2]) is not None): + # If we are looking at the final token, we allow for a + # backward-looking check to skip over a space. + # TODO: Are we sure this is the right condition here? + hms_idx = idx - 2 + + else: + hms_idx = None + + return hms_idx + + def _assign_hms(self, res, value_repr, hms): + # See GH issue #427, fixing float rounding + value = self._to_decimal(value_repr) + + if hms == 0: + # Hour + res.hour = int(value) + if value % 1: + res.minute = int(60*(value % 1)) + + elif hms == 1: + (res.minute, res.second) = self._parse_min_sec(value) + + elif hms == 2: + (res.second, res.microsecond) = self._parsems(value_repr) + + def _could_be_tzname(self, hour, tzname, tzoffset, token): + return (hour is not None and + tzname is None and + tzoffset is None and + len(token) <= 5 and + all(x in string.ascii_uppercase for x in token)) + + def _ampm_valid(self, hour, ampm, fuzzy): + """ + For fuzzy parsing, 'a' or 'am' (both valid English words) + may erroneously trigger the AM/PM flag. Deal with that + here. + """ + val_is_ampm = True + + # If there's already an AM/PM flag, this one isn't one. + if fuzzy and ampm is not None: + val_is_ampm = False + + # If AM/PM is found and hour is not, raise a ValueError + if hour is None: + if fuzzy: + val_is_ampm = False + else: + raise ValueError('No hour specified with AM or PM flag.') + elif not 0 <= hour <= 12: + # If AM/PM is found, it's a 12 hour clock, so raise + # an error for invalid range + if fuzzy: + val_is_ampm = False + else: + raise ValueError('Invalid hour specified for 12-hour clock.') + + return val_is_ampm + + def _adjust_ampm(self, hour, ampm): + if hour < 12 and ampm == 1: + hour += 12 + elif hour == 12 and ampm == 0: + hour = 0 + return hour + + def _parse_min_sec(self, value): + # TODO: Every usage of this function sets res.second to the return + # value. Are there any cases where second will be returned as None and + # we *dont* want to set res.second = None? + minute = int(value) + second = None + + sec_remainder = value % 1 + if sec_remainder: + second = int(60 * sec_remainder) + return (minute, second) + + def _parsems(self, value): + """Parse a I[.F] seconds value into (seconds, microseconds).""" + if "." not in value: + return int(value), 0 + else: + i, f = value.split(".") + return int(i), int(f.ljust(6, "0")[:6]) + + def _parse_hms(self, idx, tokens, info, hms_idx): + # TODO: Is this going to admit a lot of false-positives for when we + # just happen to have digits and "h", "m" or "s" characters in non-date + # text? I guess hex hashes won't have that problem, but there's plenty + # of random junk out there. + if hms_idx is None: + hms = None + new_idx = idx + elif hms_idx > idx: + hms = info.hms(tokens[hms_idx]) + new_idx = hms_idx + else: + # Looking backwards, increment one. + hms = info.hms(tokens[hms_idx]) + 1 + new_idx = idx + + return (new_idx, hms) + + def _recombine_skipped(self, tokens, skipped_idxs): + """ + >>> tokens = ["foo", " ", "bar", " ", "19June2000", "baz"] + >>> skipped_idxs = [0, 1, 2, 5] + >>> _recombine_skipped(tokens, skipped_idxs) + ["foo bar", "baz"] + """ + skipped_tokens = [] + for i, idx in enumerate(sorted(skipped_idxs)): + if i > 0 and idx - 1 == skipped_idxs[i - 1]: + skipped_tokens[-1] = skipped_tokens[-1] + tokens[idx] + else: + skipped_tokens.append(tokens[idx]) + + return skipped_tokens + + def _build_tzinfo(self, tzinfos, tzname, tzoffset): + if callable(tzinfos): + tzdata = tzinfos(tzname, tzoffset) + else: + tzdata = tzinfos.get(tzname) + # handle case where tzinfo is paased an options that returns None + # eg tzinfos = {'BRST' : None} + if isinstance(tzdata, datetime.tzinfo) or tzdata is None: + tzinfo = tzdata + elif isinstance(tzdata, text_type): + tzinfo = tz.tzstr(tzdata) + elif isinstance(tzdata, integer_types): + tzinfo = tz.tzoffset(tzname, tzdata) + return tzinfo + + def _build_tzaware(self, naive, res, tzinfos): + if (callable(tzinfos) or (tzinfos and res.tzname in tzinfos)): + tzinfo = self._build_tzinfo(tzinfos, res.tzname, res.tzoffset) + aware = naive.replace(tzinfo=tzinfo) + aware = self._assign_tzname(aware, res.tzname) + + elif res.tzname and res.tzname in time.tzname: + aware = naive.replace(tzinfo=tz.tzlocal()) + + # Handle ambiguous local datetime + aware = self._assign_tzname(aware, res.tzname) + + # This is mostly relevant for winter GMT zones parsed in the UK + if (aware.tzname() != res.tzname and + res.tzname in self.info.UTCZONE): + aware = aware.replace(tzinfo=tz.tzutc()) + + elif res.tzoffset == 0: + aware = naive.replace(tzinfo=tz.tzutc()) + + elif res.tzoffset: + aware = naive.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset)) + + elif not res.tzname and not res.tzoffset: + # i.e. no timezone information was found. + aware = naive + + elif res.tzname: + # tz-like string was parsed but we don't know what to do + # with it + warnings.warn("tzname {tzname} identified but not understood. " + "Pass `tzinfos` argument in order to correctly " + "return a timezone-aware datetime. In a future " + "version, this will raise an " + "exception.".format(tzname=res.tzname), + category=UnknownTimezoneWarning) + aware = naive + + return aware + + def _build_naive(self, res, default): + repl = {} + for attr in ("year", "month", "day", "hour", + "minute", "second", "microsecond"): + value = getattr(res, attr) + if value is not None: + repl[attr] = value + + if 'day' not in repl: + # If the default day exceeds the last day of the month, fall back + # to the end of the month. + cyear = default.year if res.year is None else res.year + cmonth = default.month if res.month is None else res.month + cday = default.day if res.day is None else res.day + + if cday > monthrange(cyear, cmonth)[1]: + repl['day'] = monthrange(cyear, cmonth)[1] + + naive = default.replace(**repl) + + if res.weekday is not None and not res.day: + naive = naive + relativedelta.relativedelta(weekday=res.weekday) + + return naive + + def _assign_tzname(self, dt, tzname): + if dt.tzname() != tzname: + new_dt = tz.enfold(dt, fold=1) + if new_dt.tzname() == tzname: + return new_dt + + return dt + + def _to_decimal(self, val): + try: + decimal_value = Decimal(val) + # See GH 662, edge case, infinite value should not be converted via `_to_decimal` + if not decimal_value.is_finite(): + raise ValueError("Converted decimal value is infinite or NaN") + except Exception as e: + msg = "Could not convert %s to decimal" % val + six.raise_from(ValueError(msg), e) + else: + return decimal_value + + +DEFAULTPARSER = parser() + + +def parse(timestr, parserinfo=None, **kwargs): + """ + + Parse a string in one of the supported formats, using the + ``parserinfo`` parameters. + + :param timestr: + A string containing a date/time stamp. + + :param parserinfo: + A :class:`parserinfo` object containing parameters for the parser. + If ``None``, the default arguments to the :class:`parserinfo` + constructor are used. + + The ``**kwargs`` parameter takes the following keyword arguments: + + :param default: + The default datetime object, if this is a datetime object and not + ``None``, elements specified in ``timestr`` replace elements in the + default object. + + :param ignoretz: + If set ``True``, time zones in parsed strings are ignored and a naive + :class:`datetime` object is returned. + + :param tzinfos: + Additional time zone names / aliases which may be present in the + string. This argument maps time zone names (and optionally offsets + from those time zones) to time zones. This parameter can be a + dictionary with timezone aliases mapping time zone names to time + zones or a function taking two parameters (``tzname`` and + ``tzoffset``) and returning a time zone. + + The timezones to which the names are mapped can be an integer + offset from UTC in seconds or a :class:`tzinfo` object. + + .. doctest:: + :options: +NORMALIZE_WHITESPACE + + >>> from dateutil.parser import parse + >>> from dateutil.tz import gettz + >>> tzinfos = {"BRST": -7200, "CST": gettz("America/Chicago")} + >>> parse("2012-01-19 17:21:00 BRST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, tzinfo=tzoffset(u'BRST', -7200)) + >>> parse("2012-01-19 17:21:00 CST", tzinfos=tzinfos) + datetime.datetime(2012, 1, 19, 17, 21, + tzinfo=tzfile('/usr/share/zoneinfo/America/Chicago')) + + This parameter is ignored if ``ignoretz`` is set. + + :param dayfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the day (``True``) or month (``False``). If + ``yearfirst`` is set to ``True``, this distinguishes between YDM and + YMD. If set to ``None``, this value is retrieved from the current + :class:`parserinfo` object (which itself defaults to ``False``). + + :param yearfirst: + Whether to interpret the first value in an ambiguous 3-integer date + (e.g. 01/05/09) as the year. If ``True``, the first number is taken to + be the year, otherwise the last number is taken to be the year. If + this is set to ``None``, the value is retrieved from the current + :class:`parserinfo` object (which itself defaults to ``False``). + + :param fuzzy: + Whether to allow fuzzy parsing, allowing for string like "Today is + January 1, 2047 at 8:21:00AM". + + :param fuzzy_with_tokens: + If ``True``, ``fuzzy`` is automatically set to True, and the parser + will return a tuple where the first element is the parsed + :class:`datetime.datetime` datetimestamp and the second element is + a tuple containing the portions of the string which were ignored: + + .. doctest:: + + >>> from dateutil.parser import parse + >>> parse("Today is January 1, 2047 at 8:21:00AM", fuzzy_with_tokens=True) + (datetime.datetime(2047, 1, 1, 8, 21), (u'Today is ', u' ', u'at ')) + + :return: + Returns a :class:`datetime.datetime` object or, if the + ``fuzzy_with_tokens`` option is ``True``, returns a tuple, the + first element being a :class:`datetime.datetime` object, the second + a tuple containing the fuzzy tokens. + + :raises ValueError: + Raised for invalid or unknown string format, if the provided + :class:`tzinfo` is not in a valid format, or if an invalid date + would be created. + + :raises OverflowError: + Raised if the parsed date exceeds the largest valid C integer on + your system. + """ + if parserinfo: + return parser(parserinfo).parse(timestr, **kwargs) + else: + return DEFAULTPARSER.parse(timestr, **kwargs) + + +class _tzparser(object): + + class _result(_resultbase): + + __slots__ = ["stdabbr", "stdoffset", "dstabbr", "dstoffset", + "start", "end"] + + class _attr(_resultbase): + __slots__ = ["month", "week", "weekday", + "yday", "jyday", "day", "time"] + + def __repr__(self): + return self._repr("") + + def __init__(self): + _resultbase.__init__(self) + self.start = self._attr() + self.end = self._attr() + + def parse(self, tzstr): + res = self._result() + l = [x for x in re.split(r'([,:.]|[a-zA-Z]+|[0-9]+)',tzstr) if x] + used_idxs = list() + try: + + len_l = len(l) + + i = 0 + while i < len_l: + # BRST+3[BRDT[+2]] + j = i + while j < len_l and not [x for x in l[j] + if x in "0123456789:,-+"]: + j += 1 + if j != i: + if not res.stdabbr: + offattr = "stdoffset" + res.stdabbr = "".join(l[i:j]) + else: + offattr = "dstoffset" + res.dstabbr = "".join(l[i:j]) + + for ii in range(j): + used_idxs.append(ii) + i = j + if (i < len_l and (l[i] in ('+', '-') or l[i][0] in + "0123456789")): + if l[i] in ('+', '-'): + # Yes, that's right. See the TZ variable + # documentation. + signal = (1, -1)[l[i] == '+'] + used_idxs.append(i) + i += 1 + else: + signal = -1 + len_li = len(l[i]) + if len_li == 4: + # -0300 + setattr(res, offattr, (int(l[i][:2]) * 3600 + + int(l[i][2:]) * 60) * signal) + elif i + 1 < len_l and l[i + 1] == ':': + # -03:00 + setattr(res, offattr, + (int(l[i]) * 3600 + + int(l[i + 2]) * 60) * signal) + used_idxs.append(i) + i += 2 + elif len_li <= 2: + # -[0]3 + setattr(res, offattr, + int(l[i][:2]) * 3600 * signal) + else: + return None + used_idxs.append(i) + i += 1 + if res.dstabbr: + break + else: + break + + + if i < len_l: + for j in range(i, len_l): + if l[j] == ';': + l[j] = ',' + + assert l[i] == ',' + + i += 1 + + if i >= len_l: + pass + elif (8 <= l.count(',') <= 9 and + not [y for x in l[i:] if x != ',' + for y in x if y not in "0123456789+-"]): + # GMT0BST,3,0,30,3600,10,0,26,7200[,3600] + for x in (res.start, res.end): + x.month = int(l[i]) + used_idxs.append(i) + i += 2 + if l[i] == '-': + value = int(l[i + 1]) * -1 + used_idxs.append(i) + i += 1 + else: + value = int(l[i]) + used_idxs.append(i) + i += 2 + if value: + x.week = value + x.weekday = (int(l[i]) - 1) % 7 + else: + x.day = int(l[i]) + used_idxs.append(i) + i += 2 + x.time = int(l[i]) + used_idxs.append(i) + i += 2 + if i < len_l: + if l[i] in ('-', '+'): + signal = (-1, 1)[l[i] == "+"] + used_idxs.append(i) + i += 1 + else: + signal = 1 + used_idxs.append(i) + res.dstoffset = (res.stdoffset + int(l[i]) * signal) + + # This was a made-up format that is not in normal use + warn(('Parsed time zone "%s"' % tzstr) + + 'is in a non-standard dateutil-specific format, which ' + + 'is now deprecated; support for parsing this format ' + + 'will be removed in future versions. It is recommended ' + + 'that you switch to a standard format like the GNU ' + + 'TZ variable format.', tz.DeprecatedTzFormatWarning) + elif (l.count(',') == 2 and l[i:].count('/') <= 2 and + not [y for x in l[i:] if x not in (',', '/', 'J', 'M', + '.', '-', ':') + for y in x if y not in "0123456789"]): + for x in (res.start, res.end): + if l[i] == 'J': + # non-leap year day (1 based) + used_idxs.append(i) + i += 1 + x.jyday = int(l[i]) + elif l[i] == 'M': + # month[-.]week[-.]weekday + used_idxs.append(i) + i += 1 + x.month = int(l[i]) + used_idxs.append(i) + i += 1 + assert l[i] in ('-', '.') + used_idxs.append(i) + i += 1 + x.week = int(l[i]) + if x.week == 5: + x.week = -1 + used_idxs.append(i) + i += 1 + assert l[i] in ('-', '.') + used_idxs.append(i) + i += 1 + x.weekday = (int(l[i]) - 1) % 7 + else: + # year day (zero based) + x.yday = int(l[i]) + 1 + + used_idxs.append(i) + i += 1 + + if i < len_l and l[i] == '/': + used_idxs.append(i) + i += 1 + # start time + len_li = len(l[i]) + if len_li == 4: + # -0300 + x.time = (int(l[i][:2]) * 3600 + + int(l[i][2:]) * 60) + elif i + 1 < len_l and l[i + 1] == ':': + # -03:00 + x.time = int(l[i]) * 3600 + int(l[i + 2]) * 60 + used_idxs.append(i) + i += 2 + if i + 1 < len_l and l[i + 1] == ':': + used_idxs.append(i) + i += 2 + x.time += int(l[i]) + elif len_li <= 2: + # -[0]3 + x.time = (int(l[i][:2]) * 3600) + else: + return None + used_idxs.append(i) + i += 1 + + assert i == len_l or l[i] == ',' + + i += 1 + + assert i >= len_l + + except (IndexError, ValueError, AssertionError): + return None + + unused_idxs = set(range(len_l)).difference(used_idxs) + res.any_unused_tokens = not {l[n] for n in unused_idxs}.issubset({",",":"}) + return res + + +DEFAULTTZPARSER = _tzparser() + + +def _parsetz(tzstr): + return DEFAULTTZPARSER.parse(tzstr) + +class UnknownTimezoneWarning(RuntimeWarning): + """Raised when the parser finds a timezone it cannot parse into a tzinfo""" +# vim:ts=4:sw=4:et diff --git a/libs/dateutil/parser/isoparser.py b/libs/dateutil/parser/isoparser.py new file mode 100644 index 00000000..cd27f93d --- /dev/null +++ b/libs/dateutil/parser/isoparser.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +""" +This module offers a parser for ISO-8601 strings + +It is intended to support all valid date, time and datetime formats per the +ISO-8601 specification. + +..versionadded:: 2.7.0 +""" +from datetime import datetime, timedelta, time, date +import calendar +from dateutil import tz + +from functools import wraps + +import re +import six + +__all__ = ["isoparse", "isoparser"] + + +def _takes_ascii(f): + @wraps(f) + def func(self, str_in, *args, **kwargs): + # If it's a stream, read the whole thing + str_in = getattr(str_in, 'read', lambda: str_in)() + + # If it's unicode, turn it into bytes, since ISO-8601 only covers ASCII + if isinstance(str_in, six.text_type): + # ASCII is the same in UTF-8 + try: + str_in = str_in.encode('ascii') + except UnicodeEncodeError as e: + msg = 'ISO-8601 strings should contain only ASCII characters' + six.raise_from(ValueError(msg), e) + + return f(self, str_in, *args, **kwargs) + + return func + + +class isoparser(object): + def __init__(self, sep=None): + """ + :param sep: + A single character that separates date and time portions. If + ``None``, the parser will accept any single character. + For strict ISO-8601 adherence, pass ``'T'``. + """ + if sep is not None: + if (len(sep) != 1 or ord(sep) >= 128 or sep in '0123456789'): + raise ValueError('Separator must be a single, non-numeric ' + + 'ASCII character') + + sep = sep.encode('ascii') + + self._sep = sep + + @_takes_ascii + def isoparse(self, dt_str): + """ + Parse an ISO-8601 datetime string into a :class:`datetime.datetime`. + + An ISO-8601 datetime string consists of a date portion, followed + optionally by a time portion - the date and time portions are separated + by a single character separator, which is ``T`` in the official + standard. Incomplete date formats (such as ``YYYY-MM``) may *not* be + combined with a time portion. + + Supported date formats are: + + Common: + + - ``YYYY`` + - ``YYYY-MM`` or ``YYYYMM`` + - ``YYYY-MM-DD`` or ``YYYYMMDD`` + + Uncommon: + + - ``YYYY-Www`` or ``YYYYWww`` - ISO week (day defaults to 0) + - ``YYYY-Www-D`` or ``YYYYWwwD`` - ISO week and day + + The ISO week and day numbering follows the same logic as + :func:`datetime.date.isocalendar`. + + Supported time formats are: + + - ``hh`` + - ``hh:mm`` or ``hhmm`` + - ``hh:mm:ss`` or ``hhmmss`` + - ``hh:mm:ss.sss`` or ``hh:mm:ss.ssssss`` (3-6 sub-second digits) + + Midnight is a special case for `hh`, as the standard supports both + 00:00 and 24:00 as a representation. + + .. caution:: + + Support for fractional components other than seconds is part of the + ISO-8601 standard, but is not currently implemented in this parser. + + Supported time zone offset formats are: + + - `Z` (UTC) + - `±HH:MM` + - `±HHMM` + - `±HH` + + Offsets will be represented as :class:`dateutil.tz.tzoffset` objects, + with the exception of UTC, which will be represented as + :class:`dateutil.tz.tzutc`. Time zone offsets equivalent to UTC (such + as `+00:00`) will also be represented as :class:`dateutil.tz.tzutc`. + + :param dt_str: + A string or stream containing only an ISO-8601 datetime string + + :return: + Returns a :class:`datetime.datetime` representing the string. + Unspecified components default to their lowest value. + + .. warning:: + + As of version 2.7.0, the strictness of the parser should not be + considered a stable part of the contract. Any valid ISO-8601 string + that parses correctly with the default settings will continue to + parse correctly in future versions, but invalid strings that + currently fail (e.g. ``2017-01-01T00:00+00:00:00``) are not + guaranteed to continue failing in future versions if they encode + a valid date. + + .. versionadded:: 2.7.0 + """ + components, pos = self._parse_isodate(dt_str) + + if len(dt_str) > pos: + if self._sep is None or dt_str[pos:pos + 1] == self._sep: + components += self._parse_isotime(dt_str[pos + 1:]) + else: + raise ValueError('String contains unknown ISO components') + + return datetime(*components) + + @_takes_ascii + def parse_isodate(self, datestr): + """ + Parse the date portion of an ISO string. + + :param datestr: + The string portion of an ISO string, without a separator + + :return: + Returns a :class:`datetime.date` object + """ + components, pos = self._parse_isodate(datestr) + if pos < len(datestr): + raise ValueError('String contains unknown ISO ' + + 'components: {}'.format(datestr)) + return date(*components) + + @_takes_ascii + def parse_isotime(self, timestr): + """ + Parse the time portion of an ISO string. + + :param timestr: + The time portion of an ISO string, without a separator + + :return: + Returns a :class:`datetime.time` object + """ + return time(*self._parse_isotime(timestr)) + + @_takes_ascii + def parse_tzstr(self, tzstr, zero_as_utc=True): + """ + Parse a valid ISO time zone string. + + See :func:`isoparser.isoparse` for details on supported formats. + + :param tzstr: + A string representing an ISO time zone offset + + :param zero_as_utc: + Whether to return :class:`dateutil.tz.tzutc` for zero-offset zones + + :return: + Returns :class:`dateutil.tz.tzoffset` for offsets and + :class:`dateutil.tz.tzutc` for ``Z`` and (if ``zero_as_utc`` is + specified) offsets equivalent to UTC. + """ + return self._parse_tzstr(tzstr, zero_as_utc=zero_as_utc) + + # Constants + _MICROSECOND_END_REGEX = re.compile(b'[-+Z]+') + _DATE_SEP = b'-' + _TIME_SEP = b':' + _MICRO_SEP = b'.' + + def _parse_isodate(self, dt_str): + try: + return self._parse_isodate_common(dt_str) + except ValueError: + return self._parse_isodate_uncommon(dt_str) + + def _parse_isodate_common(self, dt_str): + len_str = len(dt_str) + components = [1, 1, 1] + + if len_str < 4: + raise ValueError('ISO string too short') + + # Year + components[0] = int(dt_str[0:4]) + pos = 4 + if pos >= len_str: + return components, pos + + has_sep = dt_str[pos:pos + 1] == self._DATE_SEP + if has_sep: + pos += 1 + + # Month + if len_str - pos < 2: + raise ValueError('Invalid common month') + + components[1] = int(dt_str[pos:pos + 2]) + pos += 2 + + if pos >= len_str: + if has_sep: + return components, pos + else: + raise ValueError('Invalid ISO format') + + if has_sep: + if dt_str[pos:pos + 1] != self._DATE_SEP: + raise ValueError('Invalid separator in ISO string') + pos += 1 + + # Day + if len_str - pos < 2: + raise ValueError('Invalid common day') + components[2] = int(dt_str[pos:pos + 2]) + return components, pos + 2 + + def _parse_isodate_uncommon(self, dt_str): + if len(dt_str) < 4: + raise ValueError('ISO string too short') + + # All ISO formats start with the year + year = int(dt_str[0:4]) + + has_sep = dt_str[4:5] == self._DATE_SEP + + pos = 4 + has_sep # Skip '-' if it's there + if dt_str[pos:pos + 1] == b'W': + # YYYY-?Www-?D? + pos += 1 + weekno = int(dt_str[pos:pos + 2]) + pos += 2 + + dayno = 1 + if len(dt_str) > pos: + if (dt_str[pos:pos + 1] == self._DATE_SEP) != has_sep: + raise ValueError('Inconsistent use of dash separator') + + pos += has_sep + + dayno = int(dt_str[pos:pos + 1]) + pos += 1 + + base_date = self._calculate_weekdate(year, weekno, dayno) + else: + # YYYYDDD or YYYY-DDD + if len(dt_str) - pos < 3: + raise ValueError('Invalid ordinal day') + + ordinal_day = int(dt_str[pos:pos + 3]) + pos += 3 + + if ordinal_day < 1 or ordinal_day > (365 + calendar.isleap(year)): + raise ValueError('Invalid ordinal day' + + ' {} for year {}'.format(ordinal_day, year)) + + base_date = date(year, 1, 1) + timedelta(days=ordinal_day - 1) + + components = [base_date.year, base_date.month, base_date.day] + return components, pos + + def _calculate_weekdate(self, year, week, day): + """ + Calculate the day of corresponding to the ISO year-week-day calendar. + + This function is effectively the inverse of + :func:`datetime.date.isocalendar`. + + :param year: + The year in the ISO calendar + + :param week: + The week in the ISO calendar - range is [1, 53] + + :param day: + The day in the ISO calendar - range is [1 (MON), 7 (SUN)] + + :return: + Returns a :class:`datetime.date` + """ + if not 0 < week < 54: + raise ValueError('Invalid week: {}'.format(week)) + + if not 0 < day < 8: # Range is 1-7 + raise ValueError('Invalid weekday: {}'.format(day)) + + # Get week 1 for the specific year: + jan_4 = date(year, 1, 4) # Week 1 always has January 4th in it + week_1 = jan_4 - timedelta(days=jan_4.isocalendar()[2] - 1) + + # Now add the specific number of weeks and days to get what we want + week_offset = (week - 1) * 7 + (day - 1) + return week_1 + timedelta(days=week_offset) + + def _parse_isotime(self, timestr): + len_str = len(timestr) + components = [0, 0, 0, 0, None] + pos = 0 + comp = -1 + + if len(timestr) < 2: + raise ValueError('ISO time too short') + + has_sep = len_str >= 3 and timestr[2:3] == self._TIME_SEP + + while pos < len_str and comp < 5: + comp += 1 + + if timestr[pos:pos + 1] in b'-+Z': + # Detect time zone boundary + components[-1] = self._parse_tzstr(timestr[pos:]) + pos = len_str + break + + if comp < 3: + # Hour, minute, second + components[comp] = int(timestr[pos:pos + 2]) + pos += 2 + if (has_sep and pos < len_str and + timestr[pos:pos + 1] == self._TIME_SEP): + pos += 1 + + if comp == 3: + # Microsecond + if timestr[pos:pos + 1] != self._MICRO_SEP: + continue + + pos += 1 + us_str = self._MICROSECOND_END_REGEX.split(timestr[pos:pos + 6], + 1)[0] + + components[comp] = int(us_str) * 10**(6 - len(us_str)) + pos += len(us_str) + + if pos < len_str: + raise ValueError('Unused components in ISO string') + + if components[0] == 24: + # Standard supports 00:00 and 24:00 as representations of midnight + if any(component != 0 for component in components[1:4]): + raise ValueError('Hour may only be 24 at 24:00:00.000') + components[0] = 0 + + return components + + def _parse_tzstr(self, tzstr, zero_as_utc=True): + if tzstr == b'Z': + return tz.tzutc() + + if len(tzstr) not in {3, 5, 6}: + raise ValueError('Time zone offset must be 1, 3, 5 or 6 characters') + + if tzstr[0:1] == b'-': + mult = -1 + elif tzstr[0:1] == b'+': + mult = 1 + else: + raise ValueError('Time zone offset requires sign') + + hours = int(tzstr[1:3]) + if len(tzstr) == 3: + minutes = 0 + else: + minutes = int(tzstr[(4 if tzstr[3:4] == self._TIME_SEP else 3):]) + + if zero_as_utc and hours == 0 and minutes == 0: + return tz.tzutc() + else: + if minutes > 59: + raise ValueError('Invalid minutes in time zone offset') + + if hours > 23: + raise ValueError('Invalid hours in time zone offset') + + return tz.tzoffset(None, mult * (hours * 60 + minutes) * 60) + + +DEFAULT_ISOPARSER = isoparser() +isoparse = DEFAULT_ISOPARSER.isoparse diff --git a/libs/dateutil/relativedelta.py b/libs/dateutil/relativedelta.py index 0c72a818..1e0d6165 100644 --- a/libs/dateutil/relativedelta.py +++ b/libs/dateutil/relativedelta.py @@ -1,109 +1,96 @@ -""" -Copyright (c) 2003-2010 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. -""" -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" - +# -*- coding: utf-8 -*- import datetime import calendar +import operator +from math import copysign + +from six import integer_types +from warnings import warn + +from ._common import weekday + +MO, TU, WE, TH, FR, SA, SU = weekdays = tuple(weekday(x) for x in range(7)) + __all__ = ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"] -class weekday(object): - __slots__ = ["weekday", "n"] - def __init__(self, weekday, n=None): - self.weekday = weekday - self.n = n - - def __call__(self, n): - if n == self.n: - return self - else: - return self.__class__(self.weekday, n) - - def __eq__(self, other): - try: - if self.weekday != other.weekday or self.n != other.n: - return False - except AttributeError: - return False - return True - - def __repr__(self): - s = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[self.weekday] - if not self.n: - return s - else: - return "%s(%+d)" % (s, self.n) - -MO, TU, WE, TH, FR, SA, SU = weekdays = tuple([weekday(x) for x in range(7)]) - -class relativedelta: +class relativedelta(object): """ -The relativedelta type is based on the specification of the excelent -work done by M.-A. Lemburg in his mx.DateTime extension. However, -notice that this type does *NOT* implement the same algorithm as -his work. Do *NOT* expect it to behave like mx.DateTime's counterpart. + The relativedelta type is based on the specification of the excellent + work done by M.-A. Lemburg in his + `mx.DateTime `_ extension. + However, notice that this type does *NOT* implement the same algorithm as + his work. Do *NOT* expect it to behave like mx.DateTime's counterpart. -There's two different ways to build a relativedelta instance. The -first one is passing it two date/datetime classes: + There are two different ways to build a relativedelta instance. The + first one is passing it two date/datetime classes:: - relativedelta(datetime1, datetime2) + relativedelta(datetime1, datetime2) -And the other way is to use the following keyword arguments: + The second one is passing it any number of the following keyword arguments:: - year, month, day, hour, minute, second, microsecond: - Absolute information. + relativedelta(arg1=x,arg2=y,arg3=z...) - years, months, weeks, days, hours, minutes, seconds, microseconds: - Relative information, may be negative. + year, month, day, hour, minute, second, microsecond: + Absolute information (argument is singular); adding or subtracting a + relativedelta with absolute information does not perform an arithmetic + operation, but rather REPLACES the corresponding value in the + original datetime with the value(s) in relativedelta. - weekday: - One of the weekday instances (MO, TU, etc). These instances may - receive a parameter N, specifying the Nth weekday, which could - be positive or negative (like MO(+1) or MO(-2). Not specifying - it is the same as specifying +1. You can also use an integer, - where 0=MO. + years, months, weeks, days, hours, minutes, seconds, microseconds: + Relative information, may be negative (argument is plural); adding + or subtracting a relativedelta with relative information performs + the corresponding aritmetic operation on the original datetime value + with the information in the relativedelta. - leapdays: - Will add given days to the date found, if year is a leap - year, and the date found is post 28 of february. + weekday: + One of the weekday instances (MO, TU, etc). These + instances may receive a parameter N, specifying the Nth + weekday, which could be positive or negative (like MO(+1) + or MO(-2). Not specifying it is the same as specifying + +1. You can also use an integer, where 0=MO. Notice that + if the calculated date is already Monday, for example, + using MO(1) or MO(-1) won't change the day. - yearday, nlyearday: - Set the yearday or the non-leap year day (jump leap days). - These are converted to day/month/leapdays information. + leapdays: + Will add given days to the date found, if year is a leap + year, and the date found is post 28 of february. -Here is the behavior of operations with relativedelta: + yearday, nlyearday: + Set the yearday or the non-leap year day (jump leap days). + These are converted to day/month/leapdays information. -1) Calculate the absolute year, using the 'year' argument, or the - original datetime year, if the argument is not present. + There are relative and absolute forms of the keyword + arguments. The plural is relative, and the singular is + absolute. For each argument in the order below, the absolute form + is applied first (by setting each attribute to that value) and + then the relative form (by adding the value to the attribute). -2) Add the relative 'years' argument to the absolute year. + The order of attributes considered when this relativedelta is + added to a datetime is: -3) Do steps 1 and 2 for month/months. + 1. Year + 2. Month + 3. Day + 4. Hours + 5. Minutes + 6. Seconds + 7. Microseconds -4) Calculate the absolute day, using the 'day' argument, or the - original datetime day, if the argument is not present. Then, - subtract from the day until it fits in the year and month - found after their operations. + Finally, weekday is applied, using the rule described above. -5) Add the relative 'days' argument to the absolute day. Notice - that the 'weeks' argument is multiplied by 7 and added to - 'days'. + For example -6) Do steps 1 and 2 for hour/hours, minute/minutes, second/seconds, - microsecond/microseconds. + >>> dt = datetime(2018, 4, 9, 13, 37, 0) + >>> delta = relativedelta(hours=25, day=1, weekday=MO(1)) + datetime(2018, 4, 2, 14, 37, 0) + + First, the day is set to 1 (the first of the month), then 25 hours + are added, to get to the 2nd day and 14th hour, finally the + weekday is applied, but since the 2nd is already a Monday there is + no effect. -7) If the 'weekday' argument is present, calculate the weekday, - with the given (wday, nth) tuple. wday is the index of the - weekday (0-6, 0=Mon), and nth is the number of weeks to add - forward or backward, depending on its signal. Notice that if - the calculated date is already Monday, for example, using - (0, 1) or (0, -1) won't change the day. """ def __init__(self, dt1=None, dt2=None, @@ -112,15 +99,22 @@ Here is the behavior of operations with relativedelta: year=None, month=None, day=None, weekday=None, yearday=None, nlyearday=None, hour=None, minute=None, second=None, microsecond=None): + if dt1 and dt2: - if not isinstance(dt1, datetime.date) or \ - not isinstance(dt2, datetime.date): - raise TypeError, "relativedelta only diffs datetime/date" - if type(dt1) is not type(dt2): + # datetime is a subclass of date. So both must be date + if not (isinstance(dt1, datetime.date) and + isinstance(dt2, datetime.date)): + raise TypeError("relativedelta only diffs datetime/date") + + # We allow two dates, or two datetimes, so we coerce them to be + # of the same type + if (isinstance(dt1, datetime.datetime) != + isinstance(dt2, datetime.datetime)): if not isinstance(dt1, datetime.datetime): dt1 = datetime.datetime.fromordinal(dt1.toordinal()) elif not isinstance(dt2, datetime.datetime): dt2 = datetime.datetime.fromordinal(dt2.toordinal()) + self.years = 0 self.months = 0 self.days = 0 @@ -139,31 +133,48 @@ Here is the behavior of operations with relativedelta: self.microsecond = None self._has_time = 0 - months = (dt1.year*12+dt1.month)-(dt2.year*12+dt2.month) + # Get year / month delta between the two + months = (dt1.year - dt2.year) * 12 + (dt1.month - dt2.month) self._set_months(months) + + # Remove the year/month delta so the timedelta is just well-defined + # time units (seconds, days and microseconds) dtm = self.__radd__(dt2) + + # If we've overshot our target, make an adjustment if dt1 < dt2: - while dt1 > dtm: - months += 1 - self._set_months(months) - dtm = self.__radd__(dt2) + compare = operator.gt + increment = 1 else: - while dt1 < dtm: - months -= 1 - self._set_months(months) - dtm = self.__radd__(dt2) + compare = operator.lt + increment = -1 + + while compare(dt1, dtm): + months += increment + self._set_months(months) + dtm = self.__radd__(dt2) + + # Get the timedelta between the "months-adjusted" date and dt1 delta = dt1 - dtm - self.seconds = delta.seconds+delta.days*86400 + self.seconds = delta.seconds + delta.days * 86400 self.microseconds = delta.microseconds else: - self.years = years - self.months = months - self.days = days+weeks*7 + # Check for non-integer values in integer-only quantities + if any(x is not None and x != int(x) for x in (years, months)): + raise ValueError("Non-integer years and months are " + "ambiguous and not currently supported.") + + # Relative information + self.years = int(years) + self.months = int(months) + self.days = days + weeks * 7 self.leapdays = leapdays self.hours = hours self.minutes = minutes self.seconds = seconds self.microseconds = microseconds + + # Absolute information self.year = year self.month = month self.day = day @@ -172,7 +183,15 @@ Here is the behavior of operations with relativedelta: self.second = second self.microsecond = microsecond - if type(weekday) is int: + if any(x is not None and int(x) != x + for x in (year, month, day, hour, + minute, second, microsecond)): + # For now we'll deprecate floats - later it'll be an error. + warn("Non-integer value passed as absolute information. " + + "This is not a well-defined condition and will raise " + + "errors in future versions.", DeprecationWarning) + + if isinstance(weekday, integer_types): self.weekday = weekdays[weekday] else: self.weekday = weekday @@ -185,7 +204,8 @@ Here is the behavior of operations with relativedelta: if yearday > 59: self.leapdays = -1 if yday: - ydayidx = [31,59,90,120,151,181,212,243,273,304,334,366] + ydayidx = [31, 59, 90, 120, 151, 181, 212, + 243, 273, 304, 334, 366] for idx, ydays in enumerate(ydayidx): if yday <= ydays: self.month = idx+1 @@ -195,56 +215,143 @@ Here is the behavior of operations with relativedelta: self.day = yday-ydayidx[idx-1] break else: - raise ValueError, "invalid year day (%d)" % yday + raise ValueError("invalid year day (%d)" % yday) self._fix() def _fix(self): if abs(self.microseconds) > 999999: - s = self.microseconds//abs(self.microseconds) - div, mod = divmod(self.microseconds*s, 1000000) - self.microseconds = mod*s - self.seconds += div*s + s = _sign(self.microseconds) + div, mod = divmod(self.microseconds * s, 1000000) + self.microseconds = mod * s + self.seconds += div * s if abs(self.seconds) > 59: - s = self.seconds//abs(self.seconds) - div, mod = divmod(self.seconds*s, 60) - self.seconds = mod*s - self.minutes += div*s + s = _sign(self.seconds) + div, mod = divmod(self.seconds * s, 60) + self.seconds = mod * s + self.minutes += div * s if abs(self.minutes) > 59: - s = self.minutes//abs(self.minutes) - div, mod = divmod(self.minutes*s, 60) - self.minutes = mod*s - self.hours += div*s + s = _sign(self.minutes) + div, mod = divmod(self.minutes * s, 60) + self.minutes = mod * s + self.hours += div * s if abs(self.hours) > 23: - s = self.hours//abs(self.hours) - div, mod = divmod(self.hours*s, 24) - self.hours = mod*s - self.days += div*s + s = _sign(self.hours) + div, mod = divmod(self.hours * s, 24) + self.hours = mod * s + self.days += div * s if abs(self.months) > 11: - s = self.months//abs(self.months) - div, mod = divmod(self.months*s, 12) - self.months = mod*s - self.years += div*s - if (self.hours or self.minutes or self.seconds or self.microseconds or - self.hour is not None or self.minute is not None or - self.second is not None or self.microsecond is not None): + s = _sign(self.months) + div, mod = divmod(self.months * s, 12) + self.months = mod * s + self.years += div * s + if (self.hours or self.minutes or self.seconds or self.microseconds + or self.hour is not None or self.minute is not None or + self.second is not None or self.microsecond is not None): self._has_time = 1 else: self._has_time = 0 + @property + def weeks(self): + return int(self.days / 7.0) + + @weeks.setter + def weeks(self, value): + self.days = self.days - (self.weeks * 7) + value * 7 + def _set_months(self, months): self.months = months if abs(self.months) > 11: - s = self.months//abs(self.months) - div, mod = divmod(self.months*s, 12) - self.months = mod*s - self.years = div*s + s = _sign(self.months) + div, mod = divmod(self.months * s, 12) + self.months = mod * s + self.years = div * s else: self.years = 0 - def __radd__(self, other): + def normalized(self): + """ + Return a version of this object represented entirely using integer + values for the relative attributes. + + >>> relativedelta(days=1.5, hours=2).normalized() + relativedelta(days=1, hours=14) + + :return: + Returns a :class:`dateutil.relativedelta.relativedelta` object. + """ + # Cascade remainders down (rounding each to roughly nearest microsecond) + days = int(self.days) + + hours_f = round(self.hours + 24 * (self.days - days), 11) + hours = int(hours_f) + + minutes_f = round(self.minutes + 60 * (hours_f - hours), 10) + minutes = int(minutes_f) + + seconds_f = round(self.seconds + 60 * (minutes_f - minutes), 8) + seconds = int(seconds_f) + + microseconds = round(self.microseconds + 1e6 * (seconds_f - seconds)) + + # Constructor carries overflow back up with call to _fix() + return self.__class__(years=self.years, months=self.months, + days=days, hours=hours, minutes=minutes, + seconds=seconds, microseconds=microseconds, + leapdays=self.leapdays, year=self.year, + month=self.month, day=self.day, + weekday=self.weekday, hour=self.hour, + minute=self.minute, second=self.second, + microsecond=self.microsecond) + + def __add__(self, other): + if isinstance(other, relativedelta): + return self.__class__(years=other.years + self.years, + months=other.months + self.months, + days=other.days + self.days, + hours=other.hours + self.hours, + minutes=other.minutes + self.minutes, + seconds=other.seconds + self.seconds, + microseconds=(other.microseconds + + self.microseconds), + leapdays=other.leapdays or self.leapdays, + year=(other.year if other.year is not None + else self.year), + month=(other.month if other.month is not None + else self.month), + day=(other.day if other.day is not None + else self.day), + weekday=(other.weekday if other.weekday is not None + else self.weekday), + hour=(other.hour if other.hour is not None + else self.hour), + minute=(other.minute if other.minute is not None + else self.minute), + second=(other.second if other.second is not None + else self.second), + microsecond=(other.microsecond if other.microsecond + is not None else + self.microsecond)) + if isinstance(other, datetime.timedelta): + return self.__class__(years=self.years, + months=self.months, + days=self.days + other.days, + hours=self.hours, + minutes=self.minutes, + seconds=self.seconds + other.seconds, + microseconds=self.microseconds + other.microseconds, + leapdays=self.leapdays, + year=self.year, + month=self.month, + day=self.day, + weekday=self.weekday, + hour=self.hour, + minute=self.minute, + second=self.second, + microsecond=self.microsecond) if not isinstance(other, datetime.date): - raise TypeError, "unsupported type for add operation" + return NotImplemented elif self._has_time and not isinstance(other, datetime.datetime): other = datetime.datetime.fromordinal(other.toordinal()) year = (self.year or other.year)+self.years @@ -276,60 +383,70 @@ Here is the behavior of operations with relativedelta: microseconds=self.microseconds)) if self.weekday: weekday, nth = self.weekday.weekday, self.weekday.n or 1 - jumpdays = (abs(nth)-1)*7 + jumpdays = (abs(nth) - 1) * 7 if nth > 0: - jumpdays += (7-ret.weekday()+weekday)%7 + jumpdays += (7 - ret.weekday() + weekday) % 7 else: - jumpdays += (ret.weekday()-weekday)%7 + jumpdays += (ret.weekday() - weekday) % 7 jumpdays *= -1 ret += datetime.timedelta(days=jumpdays) return ret + def __radd__(self, other): + return self.__add__(other) + def __rsub__(self, other): return self.__neg__().__radd__(other) - def __add__(self, other): - if not isinstance(other, relativedelta): - raise TypeError, "unsupported type for add operation" - return relativedelta(years=other.years+self.years, - months=other.months+self.months, - days=other.days+self.days, - hours=other.hours+self.hours, - minutes=other.minutes+self.minutes, - seconds=other.seconds+self.seconds, - microseconds=other.microseconds+self.microseconds, - leapdays=other.leapdays or self.leapdays, - year=other.year or self.year, - month=other.month or self.month, - day=other.day or self.day, - weekday=other.weekday or self.weekday, - hour=other.hour or self.hour, - minute=other.minute or self.minute, - second=other.second or self.second, - microsecond=other.second or self.microsecond) - def __sub__(self, other): if not isinstance(other, relativedelta): - raise TypeError, "unsupported type for sub operation" - return relativedelta(years=other.years-self.years, - months=other.months-self.months, - days=other.days-self.days, - hours=other.hours-self.hours, - minutes=other.minutes-self.minutes, - seconds=other.seconds-self.seconds, - microseconds=other.microseconds-self.microseconds, - leapdays=other.leapdays or self.leapdays, - year=other.year or self.year, - month=other.month or self.month, - day=other.day or self.day, - weekday=other.weekday or self.weekday, - hour=other.hour or self.hour, - minute=other.minute or self.minute, - second=other.second or self.second, - microsecond=other.second or self.microsecond) + return NotImplemented # In case the other object defines __rsub__ + return self.__class__(years=self.years - other.years, + months=self.months - other.months, + days=self.days - other.days, + hours=self.hours - other.hours, + minutes=self.minutes - other.minutes, + seconds=self.seconds - other.seconds, + microseconds=self.microseconds - other.microseconds, + leapdays=self.leapdays or other.leapdays, + year=(self.year if self.year is not None + else other.year), + month=(self.month if self.month is not None else + other.month), + day=(self.day if self.day is not None else + other.day), + weekday=(self.weekday if self.weekday is not None else + other.weekday), + hour=(self.hour if self.hour is not None else + other.hour), + minute=(self.minute if self.minute is not None else + other.minute), + second=(self.second if self.second is not None else + other.second), + microsecond=(self.microsecond if self.microsecond + is not None else + other.microsecond)) + + def __abs__(self): + return self.__class__(years=abs(self.years), + months=abs(self.months), + days=abs(self.days), + hours=abs(self.hours), + minutes=abs(self.minutes), + seconds=abs(self.seconds), + microseconds=abs(self.microseconds), + leapdays=self.leapdays, + year=self.year, + month=self.month, + day=self.day, + weekday=self.weekday, + hour=self.hour, + minute=self.minute, + second=self.second, + microsecond=self.microsecond) def __neg__(self): - return relativedelta(years=-self.years, + return self.__class__(years=-self.years, months=-self.months, days=-self.days, hours=-self.hours, @@ -346,7 +463,7 @@ Here is the behavior of operations with relativedelta: second=self.second, microsecond=self.microsecond) - def __nonzero__(self): + def __bool__(self): return not (not self.years and not self.months and not self.days and @@ -363,16 +480,22 @@ Here is the behavior of operations with relativedelta: self.minute is None and self.second is None and self.microsecond is None) + # Compatibility with Python 2.x + __nonzero__ = __bool__ def __mul__(self, other): - f = float(other) - return relativedelta(years=self.years*f, - months=self.months*f, - days=self.days*f, - hours=self.hours*f, - minutes=self.minutes*f, - seconds=self.seconds*f, - microseconds=self.microseconds*f, + try: + f = float(other) + except TypeError: + return NotImplemented + + return self.__class__(years=int(self.years * f), + months=int(self.months * f), + days=int(self.days * f), + hours=int(self.hours * f), + minutes=int(self.minutes * f), + seconds=int(self.seconds * f), + microseconds=int(self.microseconds * f), leapdays=self.leapdays, year=self.year, month=self.month, @@ -383,9 +506,11 @@ Here is the behavior of operations with relativedelta: second=self.second, microsecond=self.microsecond) + __rmul__ = __mul__ + def __eq__(self, other): if not isinstance(other, relativedelta): - return False + return NotImplemented if self.weekday or other.weekday: if not self.weekday or not other.weekday: return False @@ -400,6 +525,7 @@ Here is the behavior of operations with relativedelta: self.hours == other.hours and self.minutes == other.minutes and self.seconds == other.seconds and + self.microseconds == other.microseconds and self.leapdays == other.leapdays and self.year == other.year and self.month == other.month and @@ -409,11 +535,38 @@ Here is the behavior of operations with relativedelta: self.second == other.second and self.microsecond == other.microsecond) + def __hash__(self): + return hash(( + self.weekday, + self.years, + self.months, + self.days, + self.hours, + self.minutes, + self.seconds, + self.microseconds, + self.leapdays, + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.microsecond, + )) + def __ne__(self, other): return not self.__eq__(other) def __div__(self, other): - return self.__mul__(1/float(other)) + try: + reciprocal = 1 / float(other) + except TypeError: + return NotImplemented + + return self.__mul__(reciprocal) + + __truediv__ = __div__ def __repr__(self): l = [] @@ -421,12 +574,17 @@ Here is the behavior of operations with relativedelta: "hours", "minutes", "seconds", "microseconds"]: value = getattr(self, attr) if value: - l.append("%s=%+d" % (attr, value)) + l.append("{attr}={value:+g}".format(attr=attr, value=value)) for attr in ["year", "month", "day", "weekday", "hour", "minute", "second", "microsecond"]: value = getattr(self, attr) if value is not None: - l.append("%s=%s" % (attr, `value`)) - return "%s(%s)" % (self.__class__.__name__, ", ".join(l)) + l.append("{attr}={value}".format(attr=attr, value=repr(value))) + return "{classname}({attrs})".format(classname=self.__class__.__name__, + attrs=", ".join(l)) + + +def _sign(x): + return int(copysign(1, x)) # vim:ts=4:sw=4:et diff --git a/libs/dateutil/rrule.py b/libs/dateutil/rrule.py index 6bd83cad..8e9c2af1 100644 --- a/libs/dateutil/rrule.py +++ b/libs/dateutil/rrule.py @@ -1,95 +1,105 @@ +# -*- coding: utf-8 -*- """ -Copyright (c) 2003-2010 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. +The rrule module offers a small, complete, and very fast, implementation of +the recurrence rules documented in the +`iCalendar RFC `_, +including support for caching of results. """ -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" - import itertools import datetime import calendar -import thread +import re import sys +try: + from math import gcd +except ImportError: + from fractions import gcd + +from six import advance_iterator, integer_types +from six.moves import _thread, range +import heapq + +from ._common import weekday as weekdaybase +from .tz import tzutc, tzlocal + +# For warning about deprecation of until and count +from warnings import warn + __all__ = ["rrule", "rruleset", "rrulestr", "YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY", "MO", "TU", "WE", "TH", "FR", "SA", "SU"] # Every mask is 7 days longer to handle cross-year weekly periods. -M366MASK = tuple([1]*31+[2]*29+[3]*31+[4]*30+[5]*31+[6]*30+ +M366MASK = tuple([1]*31+[2]*29+[3]*31+[4]*30+[5]*31+[6]*30 + [7]*31+[8]*31+[9]*30+[10]*31+[11]*30+[12]*31+[1]*7) M365MASK = list(M366MASK) -M29, M30, M31 = range(1,30), range(1,31), range(1,32) +M29, M30, M31 = list(range(1, 30)), list(range(1, 31)), list(range(1, 32)) MDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7]) MDAY365MASK = list(MDAY366MASK) -M29, M30, M31 = range(-29,0), range(-30,0), range(-31,0) +M29, M30, M31 = list(range(-29, 0)), list(range(-30, 0)), list(range(-31, 0)) NMDAY366MASK = tuple(M31+M29+M31+M30+M31+M30+M31+M31+M30+M31+M30+M31+M31[:7]) NMDAY365MASK = list(NMDAY366MASK) -M366RANGE = (0,31,60,91,121,152,182,213,244,274,305,335,366) -M365RANGE = (0,31,59,90,120,151,181,212,243,273,304,334,365) -WDAYMASK = [0,1,2,3,4,5,6]*55 +M366RANGE = (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366) +M365RANGE = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365) +WDAYMASK = [0, 1, 2, 3, 4, 5, 6]*55 del M29, M30, M31, M365MASK[59], MDAY365MASK[59], NMDAY365MASK[31] MDAY365MASK = tuple(MDAY365MASK) M365MASK = tuple(M365MASK) +FREQNAMES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY'] + (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, - SECONDLY) = range(7) + SECONDLY) = list(range(7)) # Imported on demand. easter = None parser = None -class weekday(object): - __slots__ = ["weekday", "n"] - def __init__(self, weekday, n=None): +class weekday(weekdaybase): + """ + This version of weekday does not allow n = 0. + """ + def __init__(self, wkday, n=None): if n == 0: - raise ValueError, "Can't create weekday with n == 0" - self.weekday = weekday - self.n = n + raise ValueError("Can't create weekday with n==0") - def __call__(self, n): - if n == self.n: - return self - else: - return self.__class__(self.weekday, n) + super(weekday, self).__init__(wkday, n) - def __eq__(self, other): - try: - if self.weekday != other.weekday or self.n != other.n: - return False - except AttributeError: - return False - return True - def __repr__(self): - s = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[self.weekday] - if not self.n: - return s - else: - return "%s(%+d)" % (s, self.n) +MO, TU, WE, TH, FR, SA, SU = weekdays = tuple(weekday(x) for x in range(7)) -MO, TU, WE, TH, FR, SA, SU = weekdays = tuple([weekday(x) for x in range(7)]) -class rrulebase: +def _invalidates_cache(f): + """ + Decorator for rruleset methods which may invalidate the + cached length. + """ + def inner_func(self, *args, **kwargs): + rv = f(self, *args, **kwargs) + self._invalidate_cache() + return rv + + return inner_func + + +class rrulebase(object): def __init__(self, cache=False): if cache: self._cache = [] - self._cache_lock = thread.allocate_lock() - self._cache_gen = self._iter() - self._cache_complete = False + self._cache_lock = _thread.allocate_lock() + self._invalidate_cache() else: self._cache = None self._cache_complete = False - self._len = None + self._len = None def __iter__(self): if self._cache_complete: @@ -99,6 +109,17 @@ class rrulebase: else: return self._iter_cached() + def _invalidate_cache(self): + if self._cache is not None: + self._cache = [] + self._cache_complete = False + self._cache_gen = self._iter() + + if self._cache_lock.locked(): + self._cache_lock.release() + + self._len = None + def _iter_cached(self): i = 0 gen = self._cache_gen @@ -112,7 +133,7 @@ class rrulebase: break try: for j in range(10): - cache.append(gen.next()) + cache.append(advance_iterator(gen)) except StopIteration: self._cache_gen = gen = None self._cache_complete = True @@ -133,13 +154,13 @@ class rrulebase: else: return list(itertools.islice(self, item.start or 0, - item.stop or sys.maxint, + item.stop or sys.maxsize, item.step or 1)) elif item >= 0: gen = iter(self) try: for i in range(item+1): - res = gen.next() + res = advance_iterator(gen) except StopIteration: raise IndexError return res @@ -159,11 +180,17 @@ class rrulebase: # __len__() introduces a large performance penality. def count(self): + """ Returns the number of recurrences in this set. It will have go + trough the whole recurrence, if this hasn't been done before. """ if self._len is None: - for x in self: pass + for x in self: + pass return self._len def before(self, dt, inc=False): + """ Returns the last recurrence before the given datetime instance. The + inc keyword defines what happens if dt is an occurrence. With + inc=True, if dt itself is an occurrence, it will be returned. """ if self._cache_complete: gen = self._cache else: @@ -182,6 +209,9 @@ class rrulebase: return last def after(self, dt, inc=False): + """ Returns the first recurrence after the given datetime instance. The + inc keyword defines what happens if dt is an occurrence. With + inc=True, if dt itself is an occurrence, it will be returned. """ if self._cache_complete: gen = self._cache else: @@ -196,7 +226,52 @@ class rrulebase: return i return None - def between(self, after, before, inc=False): + def xafter(self, dt, count=None, inc=False): + """ + Generator which yields up to `count` recurrences after the given + datetime instance, equivalent to `after`. + + :param dt: + The datetime at which to start generating recurrences. + + :param count: + The maximum number of recurrences to generate. If `None` (default), + dates are generated until the recurrence rule is exhausted. + + :param inc: + If `dt` is an instance of the rule and `inc` is `True`, it is + included in the output. + + :yields: Yields a sequence of `datetime` objects. + """ + + if self._cache_complete: + gen = self._cache + else: + gen = self + + # Select the comparison function + if inc: + comp = lambda dc, dtc: dc >= dtc + else: + comp = lambda dc, dtc: dc > dtc + + # Generate dates + n = 0 + for d in gen: + if comp(d, dt): + if count is not None: + n += 1 + if n > count: + break + + yield d + + def between(self, after, before, inc=False, count=1): + """ Returns all the occurrences of the rrule between after and before. + The inc keyword defines what happens if after and/or before are + themselves occurrences. With inc=True, they will be included in the + list, if they are found in the recurrence set. """ if self._cache_complete: gen = self._cache else: @@ -225,17 +300,137 @@ class rrulebase: l.append(i) return l + class rrule(rrulebase): + """ + That's the base of the rrule operation. It accepts all the keywords + defined in the RFC as its constructor parameters (except byday, + which was renamed to byweekday) and more. The constructor prototype is:: + + rrule(freq) + + Where freq must be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, + or SECONDLY. + + .. note:: + Per RFC section 3.3.10, recurrence instances falling on invalid dates + and times are ignored rather than coerced: + + Recurrence rules may generate recurrence instances with an invalid + date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM + on a day where the local time is moved forward by an hour at 1:00 + AM). Such recurrence instances MUST be ignored and MUST NOT be + counted as part of the recurrence set. + + This can lead to possibly surprising behavior when, for example, the + start date occurs at the end of the month: + + >>> from dateutil.rrule import rrule, MONTHLY + >>> from datetime import datetime + >>> start_date = datetime(2014, 12, 31) + >>> list(rrule(freq=MONTHLY, count=4, dtstart=start_date)) + ... # doctest: +NORMALIZE_WHITESPACE + [datetime.datetime(2014, 12, 31, 0, 0), + datetime.datetime(2015, 1, 31, 0, 0), + datetime.datetime(2015, 3, 31, 0, 0), + datetime.datetime(2015, 5, 31, 0, 0)] + + Additionally, it supports the following keyword arguments: + + :param dtstart: + The recurrence start. Besides being the base for the recurrence, + missing parameters in the final recurrence instances will also be + extracted from this date. If not given, datetime.now() will be used + instead. + :param interval: + The interval between each freq iteration. For example, when using + YEARLY, an interval of 2 means once every two years, but with HOURLY, + it means once every two hours. The default interval is 1. + :param wkst: + The week start day. Must be one of the MO, TU, WE constants, or an + integer, specifying the first day of the week. This will affect + recurrences based on weekly periods. The default week start is got + from calendar.firstweekday(), and may be modified by + calendar.setfirstweekday(). + :param count: + How many occurrences will be generated. + + .. note:: + As of version 2.5.0, the use of the ``until`` keyword together + with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10. + :param until: + If given, this must be a datetime instance, that will specify the + limit of the recurrence. The last recurrence in the rule is the greatest + datetime that is less than or equal to the value specified in the + ``until`` parameter. + + .. note:: + As of version 2.5.0, the use of the ``until`` keyword together + with the ``count`` keyword is deprecated per RFC-5545 Sec. 3.3.10. + :param bysetpos: + If given, it must be either an integer, or a sequence of integers, + positive or negative. Each given integer will specify an occurrence + number, corresponding to the nth occurrence of the rule inside the + frequency period. For example, a bysetpos of -1 if combined with a + MONTHLY frequency, and a byweekday of (MO, TU, WE, TH, FR), will + result in the last work day of every month. + :param bymonth: + If given, it must be either an integer, or a sequence of integers, + meaning the months to apply the recurrence to. + :param bymonthday: + If given, it must be either an integer, or a sequence of integers, + meaning the month days to apply the recurrence to. + :param byyearday: + If given, it must be either an integer, or a sequence of integers, + meaning the year days to apply the recurrence to. + :param byeaster: + If given, it must be either an integer, or a sequence of integers, + positive or negative. Each integer will define an offset from the + Easter Sunday. Passing the offset 0 to byeaster will yield the Easter + Sunday itself. This is an extension to the RFC specification. + :param byweekno: + If given, it must be either an integer, or a sequence of integers, + meaning the week numbers to apply the recurrence to. Week numbers + have the meaning described in ISO8601, that is, the first week of + the year is that containing at least four days of the new year. + :param byweekday: + If given, it must be either an integer (0 == MO), a sequence of + integers, one of the weekday constants (MO, TU, etc), or a sequence + of these constants. When given, these variables will define the + weekdays where the recurrence will be applied. It's also possible to + use an argument n for the weekday instances, which will mean the nth + occurrence of this weekday in the period. For example, with MONTHLY, + or with YEARLY and BYMONTH, using FR(+1) in byweekday will specify the + first friday of the month where the recurrence happens. Notice that in + the RFC documentation, this is specified as BYDAY, but was renamed to + avoid the ambiguity of that keyword. + :param byhour: + If given, it must be either an integer, or a sequence of integers, + meaning the hours to apply the recurrence to. + :param byminute: + If given, it must be either an integer, or a sequence of integers, + meaning the minutes to apply the recurrence to. + :param bysecond: + If given, it must be either an integer, or a sequence of integers, + meaning the seconds to apply the recurrence to. + :param cache: + If given, it must be a boolean value specifying to enable or disable + caching of results. If you will use the same rrule instance multiple + times, enabling caching will improve the performance considerably. + """ def __init__(self, freq, dtstart=None, interval=1, wkst=None, count=None, until=None, bysetpos=None, bymonth=None, bymonthday=None, byyearday=None, byeaster=None, byweekno=None, byweekday=None, byhour=None, byminute=None, bysecond=None, cache=False): - rrulebase.__init__(self, cache) + super(rrule, self).__init__(cache) global easter if not dtstart: - dtstart = datetime.datetime.now().replace(microsecond=0) + if until and until.tzinfo: + dtstart = datetime.datetime.now(tz=until.tzinfo).replace(microsecond=0) + else: + dtstart = datetime.datetime.now().replace(microsecond=0) elif not isinstance(dtstart, datetime.datetime): dtstart = datetime.datetime.fromordinal(dtstart.toordinal()) else: @@ -245,18 +440,46 @@ class rrule(rrulebase): self._freq = freq self._interval = interval self._count = count + + # Cache the original byxxx rules, if they are provided, as the _byxxx + # attributes do not necessarily map to the inputs, and this can be + # a problem in generating the strings. Only store things if they've + # been supplied (the string retrieval will just use .get()) + self._original_rule = {} + if until and not isinstance(until, datetime.datetime): until = datetime.datetime.fromordinal(until.toordinal()) self._until = until + + if self._dtstart and self._until: + if (self._dtstart.tzinfo is not None) != (self._until.tzinfo is not None): + # According to RFC5545 Section 3.3.10: + # https://tools.ietf.org/html/rfc5545#section-3.3.10 + # + # > If the "DTSTART" property is specified as a date with UTC + # > time or a date with local time and time zone reference, + # > then the UNTIL rule part MUST be specified as a date with + # > UTC time. + raise ValueError( + 'RRULE UNTIL values must be specified in UTC when DTSTART ' + 'is timezone-aware' + ) + + if count is not None and until: + warn("Using both 'count' and 'until' is inconsistent with RFC 5545" + " and has been deprecated in dateutil. Future versions will " + "raise an error.", DeprecationWarning) + if wkst is None: self._wkst = calendar.firstweekday() - elif type(wkst) is int: + elif isinstance(wkst, integer_types): self._wkst = wkst else: self._wkst = wkst.weekday + if bysetpos is None: self._bysetpos = None - elif type(bysetpos) is int: + elif isinstance(bysetpos, integer_types): if bysetpos == 0 or not (-366 <= bysetpos <= 366): raise ValueError("bysetpos must be between 1 and 366, " "or between -366 and -1") @@ -267,121 +490,192 @@ class rrule(rrulebase): if pos == 0 or not (-366 <= pos <= 366): raise ValueError("bysetpos must be between 1 and 366, " "or between -366 and -1") - if not (byweekno or byyearday or bymonthday or - byweekday is not None or byeaster is not None): + + if self._bysetpos: + self._original_rule['bysetpos'] = self._bysetpos + + if (byweekno is None and byyearday is None and bymonthday is None and + byweekday is None and byeaster is None): if freq == YEARLY: - if not bymonth: + if bymonth is None: bymonth = dtstart.month + self._original_rule['bymonth'] = None bymonthday = dtstart.day + self._original_rule['bymonthday'] = None elif freq == MONTHLY: bymonthday = dtstart.day + self._original_rule['bymonthday'] = None elif freq == WEEKLY: byweekday = dtstart.weekday() + self._original_rule['byweekday'] = None + # bymonth - if not bymonth: + if bymonth is None: self._bymonth = None - elif type(bymonth) is int: - self._bymonth = (bymonth,) else: - self._bymonth = tuple(bymonth) + if isinstance(bymonth, integer_types): + bymonth = (bymonth,) + + self._bymonth = tuple(sorted(set(bymonth))) + + if 'bymonth' not in self._original_rule: + self._original_rule['bymonth'] = self._bymonth + # byyearday - if not byyearday: + if byyearday is None: self._byyearday = None - elif type(byyearday) is int: - self._byyearday = (byyearday,) else: - self._byyearday = tuple(byyearday) + if isinstance(byyearday, integer_types): + byyearday = (byyearday,) + + self._byyearday = tuple(sorted(set(byyearday))) + self._original_rule['byyearday'] = self._byyearday + # byeaster if byeaster is not None: if not easter: from dateutil import easter - if type(byeaster) is int: + if isinstance(byeaster, integer_types): self._byeaster = (byeaster,) else: - self._byeaster = tuple(byeaster) + self._byeaster = tuple(sorted(byeaster)) + + self._original_rule['byeaster'] = self._byeaster else: self._byeaster = None - # bymonthay - if not bymonthday: + + # bymonthday + if bymonthday is None: self._bymonthday = () self._bynmonthday = () - elif type(bymonthday) is int: - if bymonthday < 0: - self._bynmonthday = (bymonthday,) - self._bymonthday = () - else: - self._bymonthday = (bymonthday,) - self._bynmonthday = () else: - self._bymonthday = tuple([x for x in bymonthday if x > 0]) - self._bynmonthday = tuple([x for x in bymonthday if x < 0]) + if isinstance(bymonthday, integer_types): + bymonthday = (bymonthday,) + + bymonthday = set(bymonthday) # Ensure it's unique + + self._bymonthday = tuple(sorted(x for x in bymonthday if x > 0)) + self._bynmonthday = tuple(sorted(x for x in bymonthday if x < 0)) + + # Storing positive numbers first, then negative numbers + if 'bymonthday' not in self._original_rule: + self._original_rule['bymonthday'] = tuple( + itertools.chain(self._bymonthday, self._bynmonthday)) + # byweekno if byweekno is None: self._byweekno = None - elif type(byweekno) is int: - self._byweekno = (byweekno,) else: - self._byweekno = tuple(byweekno) + if isinstance(byweekno, integer_types): + byweekno = (byweekno,) + + self._byweekno = tuple(sorted(set(byweekno))) + + self._original_rule['byweekno'] = self._byweekno + # byweekday / bynweekday if byweekday is None: self._byweekday = None self._bynweekday = None - elif type(byweekday) is int: - self._byweekday = (byweekday,) - self._bynweekday = None - elif hasattr(byweekday, "n"): - if not byweekday.n or freq > MONTHLY: - self._byweekday = (byweekday.weekday,) - self._bynweekday = None - else: - self._bynweekday = ((byweekday.weekday, byweekday.n),) - self._byweekday = None else: - self._byweekday = [] - self._bynweekday = [] + # If it's one of the valid non-sequence types, convert to a + # single-element sequence before the iterator that builds the + # byweekday set. + if isinstance(byweekday, integer_types) or hasattr(byweekday, "n"): + byweekday = (byweekday,) + + self._byweekday = set() + self._bynweekday = set() for wday in byweekday: - if type(wday) is int: - self._byweekday.append(wday) + if isinstance(wday, integer_types): + self._byweekday.add(wday) elif not wday.n or freq > MONTHLY: - self._byweekday.append(wday.weekday) + self._byweekday.add(wday.weekday) else: - self._bynweekday.append((wday.weekday, wday.n)) - self._byweekday = tuple(self._byweekday) - self._bynweekday = tuple(self._bynweekday) + self._bynweekday.add((wday.weekday, wday.n)) + if not self._byweekday: self._byweekday = None elif not self._bynweekday: self._bynweekday = None + + if self._byweekday is not None: + self._byweekday = tuple(sorted(self._byweekday)) + orig_byweekday = [weekday(x) for x in self._byweekday] + else: + orig_byweekday = () + + if self._bynweekday is not None: + self._bynweekday = tuple(sorted(self._bynweekday)) + orig_bynweekday = [weekday(*x) for x in self._bynweekday] + else: + orig_bynweekday = () + + if 'byweekday' not in self._original_rule: + self._original_rule['byweekday'] = tuple(itertools.chain( + orig_byweekday, orig_bynweekday)) + # byhour if byhour is None: if freq < HOURLY: - self._byhour = (dtstart.hour,) + self._byhour = {dtstart.hour} else: self._byhour = None - elif type(byhour) is int: - self._byhour = (byhour,) else: - self._byhour = tuple(byhour) + if isinstance(byhour, integer_types): + byhour = (byhour,) + + if freq == HOURLY: + self._byhour = self.__construct_byset(start=dtstart.hour, + byxxx=byhour, + base=24) + else: + self._byhour = set(byhour) + + self._byhour = tuple(sorted(self._byhour)) + self._original_rule['byhour'] = self._byhour + # byminute if byminute is None: if freq < MINUTELY: - self._byminute = (dtstart.minute,) + self._byminute = {dtstart.minute} else: self._byminute = None - elif type(byminute) is int: - self._byminute = (byminute,) else: - self._byminute = tuple(byminute) + if isinstance(byminute, integer_types): + byminute = (byminute,) + + if freq == MINUTELY: + self._byminute = self.__construct_byset(start=dtstart.minute, + byxxx=byminute, + base=60) + else: + self._byminute = set(byminute) + + self._byminute = tuple(sorted(self._byminute)) + self._original_rule['byminute'] = self._byminute + # bysecond if bysecond is None: if freq < SECONDLY: - self._bysecond = (dtstart.second,) + self._bysecond = ((dtstart.second,)) else: self._bysecond = None - elif type(bysecond) is int: - self._bysecond = (bysecond,) else: - self._bysecond = tuple(bysecond) + if isinstance(bysecond, integer_types): + bysecond = (bysecond,) + + self._bysecond = set(bysecond) + + if freq == SECONDLY: + self._bysecond = self.__construct_byset(start=dtstart.second, + byxxx=bysecond, + base=60) + else: + self._bysecond = set(bysecond) + + self._bysecond = tuple(sorted(self._bysecond)) + self._original_rule['bysecond'] = self._bysecond if self._freq >= HOURLY: self._timeset = None @@ -391,11 +685,87 @@ class rrule(rrulebase): for minute in self._byminute: for second in self._bysecond: self._timeset.append( - datetime.time(hour, minute, second, - tzinfo=self._tzinfo)) + datetime.time(hour, minute, second, + tzinfo=self._tzinfo)) self._timeset.sort() self._timeset = tuple(self._timeset) + def __str__(self): + """ + Output a string that would generate this RRULE if passed to rrulestr. + This is mostly compatible with RFC5545, except for the + dateutil-specific extension BYEASTER. + """ + + output = [] + h, m, s = [None] * 3 + if self._dtstart: + output.append(self._dtstart.strftime('DTSTART:%Y%m%dT%H%M%S')) + h, m, s = self._dtstart.timetuple()[3:6] + + parts = ['FREQ=' + FREQNAMES[self._freq]] + if self._interval != 1: + parts.append('INTERVAL=' + str(self._interval)) + + if self._wkst: + parts.append('WKST=' + repr(weekday(self._wkst))[0:2]) + + if self._count is not None: + parts.append('COUNT=' + str(self._count)) + + if self._until: + parts.append(self._until.strftime('UNTIL=%Y%m%dT%H%M%S')) + + if self._original_rule.get('byweekday') is not None: + # The str() method on weekday objects doesn't generate + # RFC5545-compliant strings, so we should modify that. + original_rule = dict(self._original_rule) + wday_strings = [] + for wday in original_rule['byweekday']: + if wday.n: + wday_strings.append('{n:+d}{wday}'.format( + n=wday.n, + wday=repr(wday)[0:2])) + else: + wday_strings.append(repr(wday)) + + original_rule['byweekday'] = wday_strings + else: + original_rule = self._original_rule + + partfmt = '{name}={vals}' + for name, key in [('BYSETPOS', 'bysetpos'), + ('BYMONTH', 'bymonth'), + ('BYMONTHDAY', 'bymonthday'), + ('BYYEARDAY', 'byyearday'), + ('BYWEEKNO', 'byweekno'), + ('BYDAY', 'byweekday'), + ('BYHOUR', 'byhour'), + ('BYMINUTE', 'byminute'), + ('BYSECOND', 'bysecond'), + ('BYEASTER', 'byeaster')]: + value = original_rule.get(key) + if value: + parts.append(partfmt.format(name=name, vals=(','.join(str(v) + for v in value)))) + + output.append('RRULE:' + ';'.join(parts)) + return '\n'.join(output) + + def replace(self, **kwargs): + """Return new rrule with same attributes except for those attributes given new + values by whichever keyword arguments are specified.""" + new_kwargs = {"interval": self._interval, + "count": self._count, + "dtstart": self._dtstart, + "freq": self._freq, + "until": self._until, + "wkst": self._wkst, + "cache": False if self._cache is None else True } + new_kwargs.update(self._original_rule) + new_kwargs.update(kwargs) + return rrule(**new_kwargs) + def _iter(self): year, month, day, hour, minute, second, weekday, yearday, _ = \ self._dtstart.timetuple() @@ -420,20 +790,20 @@ class rrule(rrulebase): ii = _iterinfo(self) ii.rebuild(year, month) - getdayset = {YEARLY:ii.ydayset, - MONTHLY:ii.mdayset, - WEEKLY:ii.wdayset, - DAILY:ii.ddayset, - HOURLY:ii.ddayset, - MINUTELY:ii.ddayset, - SECONDLY:ii.ddayset}[freq] - + getdayset = {YEARLY: ii.ydayset, + MONTHLY: ii.mdayset, + WEEKLY: ii.wdayset, + DAILY: ii.ddayset, + HOURLY: ii.ddayset, + MINUTELY: ii.ddayset, + SECONDLY: ii.ddayset}[freq] + if freq < HOURLY: timeset = self._timeset else: - gettimeset = {HOURLY:ii.htimeset, - MINUTELY:ii.mtimeset, - SECONDLY:ii.stimeset}[freq] + gettimeset = {HOURLY: ii.htimeset, + MINUTELY: ii.mtimeset, + SECONDLY: ii.stimeset}[freq] if ((freq >= HOURLY and self._byhour and hour not in self._byhour) or (freq >= MINUTELY and @@ -462,11 +832,10 @@ class rrule(rrulebase): ii.mdaymask[i] not in bymonthday and ii.nmdaymask[i] not in bynmonthday) or (byyearday and - ((i < ii.yearlen and i+1 not in byyearday - and -ii.yearlen+i not in byyearday) or - (i >= ii.yearlen and i+1-ii.yearlen not in byyearday - and -ii.nextyearlen+i-ii.yearlen - not in byyearday)))): + ((i < ii.yearlen and i+1 not in byyearday and + -ii.yearlen+i not in byyearday) or + (i >= ii.yearlen and i+1-ii.yearlen not in byyearday and + -ii.nextyearlen+i-ii.yearlen not in byyearday)))): dayset[i] = None filtered = True @@ -480,7 +849,7 @@ class rrule(rrulebase): daypos, timepos = divmod(pos-1, len(timeset)) try: i = [x for x in dayset[start:end] - if x is not None][daypos] + if x is not None][daypos] time = timeset[timepos] except IndexError: pass @@ -495,31 +864,32 @@ class rrule(rrulebase): self._len = total return elif res >= self._dtstart: - total += 1 - yield res - if count: + if count is not None: count -= 1 - if not count: + if count < 0: self._len = total return + total += 1 + yield res else: for i in dayset[start:end]: if i is not None: - date = datetime.date.fromordinal(ii.yearordinal+i) + date = datetime.date.fromordinal(ii.yearordinal + i) for time in timeset: res = datetime.datetime.combine(date, time) if until and res > until: self._len = total return elif res >= self._dtstart: - total += 1 - yield res - if count: + if count is not None: count -= 1 - if not count: + if count < 0: self._len = total return + total += 1 + yield res + # Handle frequency and interval fixday = False if freq == YEARLY: @@ -555,60 +925,86 @@ class rrule(rrulebase): if filtered: # Jump to one iteration before next day hour += ((23-hour)//interval)*interval - while True: - hour += interval - div, mod = divmod(hour, 24) - if div: - hour = mod - day += div - fixday = True - if not byhour or hour in byhour: - break + + if byhour: + ndays, hour = self.__mod_distance(value=hour, + byxxx=self._byhour, + base=24) + else: + ndays, hour = divmod(hour+interval, 24) + + if ndays: + day += ndays + fixday = True + timeset = gettimeset(hour, minute, second) elif freq == MINUTELY: if filtered: # Jump to one iteration before next day minute += ((1439-(hour*60+minute))//interval)*interval - while True: - minute += interval - div, mod = divmod(minute, 60) + + valid = False + rep_rate = (24*60) + for j in range(rep_rate // gcd(interval, rep_rate)): + if byminute: + nhours, minute = \ + self.__mod_distance(value=minute, + byxxx=self._byminute, + base=60) + else: + nhours, minute = divmod(minute+interval, 60) + + div, hour = divmod(hour+nhours, 24) if div: - minute = mod - hour += div - div, mod = divmod(hour, 24) - if div: - hour = mod - day += div - fixday = True - filtered = False - if ((not byhour or hour in byhour) and - (not byminute or minute in byminute)): + day += div + fixday = True + filtered = False + + if not byhour or hour in byhour: + valid = True break + + if not valid: + raise ValueError('Invalid combination of interval and ' + + 'byhour resulting in empty rule.') + timeset = gettimeset(hour, minute, second) elif freq == SECONDLY: if filtered: # Jump to one iteration before next day - second += (((86399-(hour*3600+minute*60+second)) - //interval)*interval) - while True: - second += self._interval - div, mod = divmod(second, 60) + second += (((86399 - (hour * 3600 + minute * 60 + second)) + // interval) * interval) + + rep_rate = (24 * 3600) + valid = False + for j in range(0, rep_rate // gcd(interval, rep_rate)): + if bysecond: + nminutes, second = \ + self.__mod_distance(value=second, + byxxx=self._bysecond, + base=60) + else: + nminutes, second = divmod(second+interval, 60) + + div, minute = divmod(minute+nminutes, 60) if div: - second = mod - minute += div - div, mod = divmod(minute, 60) + hour += div + div, hour = divmod(hour, 24) if div: - minute = mod - hour += div - div, mod = divmod(hour, 24) - if div: - hour = mod - day += div - fixday = True + day += div + fixday = True + if ((not byhour or hour in byhour) and - (not byminute or minute in byminute) and - (not bysecond or second in bysecond)): + (not byminute or minute in byminute) and + (not bysecond or second in bysecond)): + valid = True break + + if not valid: + raise ValueError('Invalid combination of interval, ' + + 'byhour and byminute resulting in empty' + + ' rule.') + timeset = gettimeset(hour, minute, second) if fixday and day > 28: @@ -626,6 +1022,86 @@ class rrule(rrulebase): daysinmonth = calendar.monthrange(year, month)[1] ii.rebuild(year, month) + def __construct_byset(self, start, byxxx, base): + """ + If a `BYXXX` sequence is passed to the constructor at the same level as + `FREQ` (e.g. `FREQ=HOURLY,BYHOUR={2,4,7},INTERVAL=3`), there are some + specifications which cannot be reached given some starting conditions. + + This occurs whenever the interval is not coprime with the base of a + given unit and the difference between the starting position and the + ending position is not coprime with the greatest common denominator + between the interval and the base. For example, with a FREQ of hourly + starting at 17:00 and an interval of 4, the only valid values for + BYHOUR would be {21, 1, 5, 9, 13, 17}, because 4 and 24 are not + coprime. + + :param start: + Specifies the starting position. + :param byxxx: + An iterable containing the list of allowed values. + :param base: + The largest allowable value for the specified frequency (e.g. + 24 hours, 60 minutes). + + This does not preserve the type of the iterable, returning a set, since + the values should be unique and the order is irrelevant, this will + speed up later lookups. + + In the event of an empty set, raises a :exception:`ValueError`, as this + results in an empty rrule. + """ + + cset = set() + + # Support a single byxxx value. + if isinstance(byxxx, integer_types): + byxxx = (byxxx, ) + + for num in byxxx: + i_gcd = gcd(self._interval, base) + # Use divmod rather than % because we need to wrap negative nums. + if i_gcd == 1 or divmod(num - start, i_gcd)[1] == 0: + cset.add(num) + + if len(cset) == 0: + raise ValueError("Invalid rrule byxxx generates an empty set.") + + return cset + + def __mod_distance(self, value, byxxx, base): + """ + Calculates the next value in a sequence where the `FREQ` parameter is + specified along with a `BYXXX` parameter at the same "level" + (e.g. `HOURLY` specified with `BYHOUR`). + + :param value: + The old value of the component. + :param byxxx: + The `BYXXX` set, which should have been generated by + `rrule._construct_byset`, or something else which checks that a + valid rule is present. + :param base: + The largest allowable value for the specified frequency (e.g. + 24 hours, 60 minutes). + + If a valid value is not found after `base` iterations (the maximum + number before the sequence would start to repeat), this raises a + :exception:`ValueError`, as no valid values were found. + + This returns a tuple of `divmod(n*interval, base)`, where `n` is the + smallest number of `interval` repetitions until the next specified + value in `byxxx` is found. + """ + accumulator = 0 + for ii in range(1, base + 1): + # Using divmod() over % to account for negative intervals + div, value = divmod(value + self._interval, base) + accumulator += div + if value in byxxx: + return (accumulator, value) + + class _iterinfo(object): __slots__ = ["rrule", "lastyear", "lastmonth", "yearlen", "nextyearlen", "yearordinal", "yearweekday", @@ -641,8 +1117,8 @@ class _iterinfo(object): # Every mask is 7 days longer to handle cross-year weekly periods. rr = self.rrule if year != self.lastyear: - self.yearlen = 365+calendar.isleap(year) - self.nextyearlen = 365+calendar.isleap(year+1) + self.yearlen = 365 + calendar.isleap(year) + self.nextyearlen = 365 + calendar.isleap(year + 1) firstyday = datetime.date(year, 1, 1) self.yearordinal = firstyday.toordinal() self.yearweekday = firstyday.weekday() @@ -665,13 +1141,13 @@ class _iterinfo(object): self.wnomask = None else: self.wnomask = [0]*(self.yearlen+7) - #no1wkst = firstwkst = self.wdaymask.index(rr._wkst) - no1wkst = firstwkst = (7-self.yearweekday+rr._wkst)%7 + # no1wkst = firstwkst = self.wdaymask.index(rr._wkst) + no1wkst = firstwkst = (7-self.yearweekday+rr._wkst) % 7 if no1wkst >= 4: no1wkst = 0 # Number of days in the year, plus the days we got # from last year. - wyearlen = self.yearlen+(self.yearweekday-rr._wkst)%7 + wyearlen = self.yearlen+(self.yearweekday-rr._wkst) % 7 else: # Number of days in the year, minus the days we # left in last year. @@ -716,23 +1192,23 @@ class _iterinfo(object): # days from last year's last week number in # this year. if -1 not in rr._byweekno: - lyearweekday = datetime.date(year-1,1,1).weekday() - lno1wkst = (7-lyearweekday+rr._wkst)%7 + lyearweekday = datetime.date(year-1, 1, 1).weekday() + lno1wkst = (7-lyearweekday+rr._wkst) % 7 lyearlen = 365+calendar.isleap(year-1) if lno1wkst >= 4: lno1wkst = 0 - lnumweeks = 52+(lyearlen+ - (lyearweekday-rr._wkst)%7)%7//4 + lnumweeks = 52+(lyearlen + + (lyearweekday-rr._wkst) % 7) % 7//4 else: - lnumweeks = 52+(self.yearlen-no1wkst)%7//4 + lnumweeks = 52+(self.yearlen-no1wkst) % 7//4 else: lnumweeks = -1 if lnumweeks in rr._byweekno: for i in range(no1wkst): self.wnomask[i] = 1 - if (rr._bynweekday and - (month != self.lastmonth or year != self.lastyear)): + if (rr._bynweekday and (month != self.lastmonth or + year != self.lastyear)): ranges = [] if rr._freq == YEARLY: if rr._bymonth: @@ -751,10 +1227,10 @@ class _iterinfo(object): for wday, n in rr._bynweekday: if n < 0: i = last+(n+1)*7 - i -= (self.wdaymask[i]-wday)%7 + i -= (self.wdaymask[i]-wday) % 7 else: i = first+(n-1)*7 - i += (7-self.wdaymask[i]+wday)%7 + i += (7-self.wdaymask[i]+wday) % 7 if first <= i <= last: self.nwdaymask[i] = 1 @@ -768,53 +1244,53 @@ class _iterinfo(object): self.lastmonth = month def ydayset(self, year, month, day): - return range(self.yearlen), 0, self.yearlen + return list(range(self.yearlen)), 0, self.yearlen def mdayset(self, year, month, day): - set = [None]*self.yearlen + dset = [None]*self.yearlen start, end = self.mrange[month-1:month+1] for i in range(start, end): - set[i] = i - return set, start, end + dset[i] = i + return dset, start, end def wdayset(self, year, month, day): # We need to handle cross-year weeks here. - set = [None]*(self.yearlen+7) + dset = [None]*(self.yearlen+7) i = datetime.date(year, month, day).toordinal()-self.yearordinal start = i for j in range(7): - set[i] = i + dset[i] = i i += 1 - #if (not (0 <= i < self.yearlen) or + # if (not (0 <= i < self.yearlen) or # self.wdaymask[i] == self.rrule._wkst): # This will cross the year boundary, if necessary. if self.wdaymask[i] == self.rrule._wkst: break - return set, start, i + return dset, start, i def ddayset(self, year, month, day): - set = [None]*self.yearlen - i = datetime.date(year, month, day).toordinal()-self.yearordinal - set[i] = i - return set, i, i+1 + dset = [None] * self.yearlen + i = datetime.date(year, month, day).toordinal() - self.yearordinal + dset[i] = i + return dset, i, i + 1 def htimeset(self, hour, minute, second): - set = [] + tset = [] rr = self.rrule for minute in rr._byminute: for second in rr._bysecond: - set.append(datetime.time(hour, minute, second, - tzinfo=rr._tzinfo)) - set.sort() - return set + tset.append(datetime.time(hour, minute, second, + tzinfo=rr._tzinfo)) + tset.sort() + return tset def mtimeset(self, hour, minute, second): - set = [] + tset = [] rr = self.rrule for second in rr._bysecond: - set.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo)) - set.sort() - return set + tset.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo)) + tset.sort() + return tset def stimeset(self, hour, minute, second): return (datetime.time(hour, minute, second, @@ -822,75 +1298,115 @@ class _iterinfo(object): class rruleset(rrulebase): + """ The rruleset type allows more complex recurrence setups, mixing + multiple rules, dates, exclusion rules, and exclusion dates. The type + constructor takes the following keyword arguments: - class _genitem: + :param cache: If True, caching of results will be enabled, improving + performance of multiple queries considerably. """ + + class _genitem(object): def __init__(self, genlist, gen): try: - self.dt = gen() + self.dt = advance_iterator(gen) genlist.append(self) except StopIteration: pass self.genlist = genlist self.gen = gen - def next(self): + def __next__(self): try: - self.dt = self.gen() + self.dt = advance_iterator(self.gen) except StopIteration: - self.genlist.remove(self) + if self.genlist[0] is self: + heapq.heappop(self.genlist) + else: + self.genlist.remove(self) + heapq.heapify(self.genlist) - def __cmp__(self, other): - return cmp(self.dt, other.dt) + next = __next__ + + def __lt__(self, other): + return self.dt < other.dt + + def __gt__(self, other): + return self.dt > other.dt + + def __eq__(self, other): + return self.dt == other.dt + + def __ne__(self, other): + return self.dt != other.dt def __init__(self, cache=False): - rrulebase.__init__(self, cache) + super(rruleset, self).__init__(cache) self._rrule = [] self._rdate = [] self._exrule = [] self._exdate = [] + @_invalidates_cache def rrule(self, rrule): + """ Include the given :py:class:`rrule` instance in the recurrence set + generation. """ self._rrule.append(rrule) - + + @_invalidates_cache def rdate(self, rdate): + """ Include the given :py:class:`datetime` instance in the recurrence + set generation. """ self._rdate.append(rdate) + @_invalidates_cache def exrule(self, exrule): + """ Include the given rrule instance in the recurrence set exclusion + list. Dates which are part of the given recurrence rules will not + be generated, even if some inclusive rrule or rdate matches them. + """ self._exrule.append(exrule) + @_invalidates_cache def exdate(self, exdate): + """ Include the given datetime instance in the recurrence set + exclusion list. Dates included that way will not be generated, + even if some inclusive rrule or rdate matches them. """ self._exdate.append(exdate) def _iter(self): rlist = [] self._rdate.sort() - self._genitem(rlist, iter(self._rdate).next) - for gen in [iter(x).next for x in self._rrule]: + self._genitem(rlist, iter(self._rdate)) + for gen in [iter(x) for x in self._rrule]: self._genitem(rlist, gen) - rlist.sort() exlist = [] self._exdate.sort() - self._genitem(exlist, iter(self._exdate).next) - for gen in [iter(x).next for x in self._exrule]: + self._genitem(exlist, iter(self._exdate)) + for gen in [iter(x) for x in self._exrule]: self._genitem(exlist, gen) - exlist.sort() lastdt = None total = 0 + heapq.heapify(rlist) + heapq.heapify(exlist) while rlist: ritem = rlist[0] if not lastdt or lastdt != ritem.dt: while exlist and exlist[0] < ritem: - exlist[0].next() - exlist.sort() + exitem = exlist[0] + advance_iterator(exitem) + if exlist and exlist[0] is exitem: + heapq.heapreplace(exlist, exitem) if not exlist or ritem != exlist[0]: total += 1 yield ritem.dt lastdt = ritem.dt - ritem.next() - rlist.sort() + advance_iterator(ritem) + if rlist and rlist[0] is ritem: + heapq.heapreplace(rlist, ritem) self._len = total -class _rrulestr: + +class _rrulestr(object): _freq_map = {"YEARLY": YEARLY, "MONTHLY": MONTHLY, @@ -900,7 +1416,8 @@ class _rrulestr: "MINUTELY": MINUTELY, "SECONDLY": SECONDLY} - _weekday_map = {"MO":0,"TU":1,"WE":2,"TH":3,"FR":4,"SA":5,"SU":6} + _weekday_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, + "FR": 4, "SA": 5, "SU": 6} def _handle_int(self, rrkwargs, name, value, **kwargs): rrkwargs[name.lower()] = int(value) @@ -908,17 +1425,17 @@ class _rrulestr: def _handle_int_list(self, rrkwargs, name, value, **kwargs): rrkwargs[name.lower()] = [int(x) for x in value.split(',')] - _handle_INTERVAL = _handle_int - _handle_COUNT = _handle_int - _handle_BYSETPOS = _handle_int_list - _handle_BYMONTH = _handle_int_list + _handle_INTERVAL = _handle_int + _handle_COUNT = _handle_int + _handle_BYSETPOS = _handle_int_list + _handle_BYMONTH = _handle_int_list _handle_BYMONTHDAY = _handle_int_list - _handle_BYYEARDAY = _handle_int_list - _handle_BYEASTER = _handle_int_list - _handle_BYWEEKNO = _handle_int_list - _handle_BYHOUR = _handle_int_list - _handle_BYMINUTE = _handle_int_list - _handle_BYSECOND = _handle_int_list + _handle_BYYEARDAY = _handle_int_list + _handle_BYEASTER = _handle_int_list + _handle_BYWEEKNO = _handle_int_list + _handle_BYHOUR = _handle_int_list + _handle_BYMINUTE = _handle_int_list + _handle_BYSECOND = _handle_int_list def _handle_FREQ(self, rrkwargs, name, value, **kwargs): rrkwargs["freq"] = self._freq_map[value] @@ -929,23 +1446,37 @@ class _rrulestr: from dateutil import parser try: rrkwargs["until"] = parser.parse(value, - ignoretz=kwargs.get("ignoretz"), - tzinfos=kwargs.get("tzinfos")) + ignoretz=kwargs.get("ignoretz"), + tzinfos=kwargs.get("tzinfos")) except ValueError: - raise ValueError, "invalid until date" + raise ValueError("invalid until date") def _handle_WKST(self, rrkwargs, name, value, **kwargs): rrkwargs["wkst"] = self._weekday_map[value] - def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwarsg): + def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwargs): + """ + Two ways to specify this: +1MO or MO(+1) + """ l = [] for wday in value.split(','): - for i in range(len(wday)): - if wday[i] not in '+-0123456789': - break - n = wday[:i] or None - w = wday[i:] - if n: n = int(n) + if '(' in wday: + # If it's of the form TH(+1), etc. + splt = wday.split('(') + w = splt[0] + n = int(splt[1][:-1]) + elif len(wday): + # If it's of the form +1MO + for i in range(len(wday)): + if wday[i] not in '+-0123456789': + break + n = wday[:i] or None + w = wday[i:] + if n: + n = int(n) + else: + raise ValueError("Invalid (empty) BYDAY specification.") + l.append(weekdays[self._weekday_map[w]](n)) rrkwargs["byweekday"] = l @@ -959,7 +1490,7 @@ class _rrulestr: if line.find(':') != -1: name, value = line.split(':') if name != "RRULE": - raise ValueError, "unknown parameter name" + raise ValueError("unknown parameter name") else: value = line rrkwargs = {} @@ -972,9 +1503,9 @@ class _rrulestr: ignoretz=ignoretz, tzinfos=tzinfos) except AttributeError: - raise ValueError, "unknown parameter '%s'" % name + raise ValueError("unknown parameter '%s'" % name) except (KeyError, ValueError): - raise ValueError, "invalid '%s': %s" % (name, value) + raise ValueError("invalid '%s': %s" % (name, value)) return rrule(dtstart=dtstart, cache=cache, **rrkwargs) def _parse_rfc(self, s, @@ -984,14 +1515,20 @@ class _rrulestr: forceset=False, compatible=False, ignoretz=False, + tzids=None, tzinfos=None): global parser if compatible: forceset = True unfold = True + + TZID_NAMES = dict(map( + lambda x: (x.upper(), x), + re.findall('TZID=(?P[^:]+):', s) + )) s = s.upper() if not s.strip(): - raise ValueError, "empty string" + raise ValueError("empty string") if unfold: lines = s.splitlines() i = 0 @@ -1006,8 +1543,8 @@ class _rrulestr: i += 1 else: lines = s.split() - if (not forceset and len(lines) == 1 and - (s.find(':') == -1 or s.startswith('RRULE:'))): + if (not forceset and len(lines) == 1 and (s.find(':') == -1 or + s.startswith('RRULE:'))): return self._parse_rfc_rrule(lines[0], cache=cache, dtstart=dtstart, ignoretz=ignoretz, tzinfos=tzinfos) @@ -1026,62 +1563,99 @@ class _rrulestr: name, value = line.split(':', 1) parms = name.split(';') if not parms: - raise ValueError, "empty property name" + raise ValueError("empty property name") name = parms[0] parms = parms[1:] if name == "RRULE": for parm in parms: - raise ValueError, "unsupported RRULE parm: "+parm + raise ValueError("unsupported RRULE parm: "+parm) rrulevals.append(value) elif name == "RDATE": for parm in parms: if parm != "VALUE=DATE-TIME": - raise ValueError, "unsupported RDATE parm: "+parm + raise ValueError("unsupported RDATE parm: "+parm) rdatevals.append(value) elif name == "EXRULE": for parm in parms: - raise ValueError, "unsupported EXRULE parm: "+parm + raise ValueError("unsupported EXRULE parm: "+parm) exrulevals.append(value) elif name == "EXDATE": for parm in parms: if parm != "VALUE=DATE-TIME": - raise ValueError, "unsupported RDATE parm: "+parm + raise ValueError("unsupported EXDATE parm: "+parm) exdatevals.append(value) elif name == "DTSTART": + # RFC 5445 3.8.2.4: The VALUE parameter is optional, but + # may be found only once. + value_found = False + TZID = None + valid_values = {"VALUE=DATE-TIME", "VALUE=DATE"} for parm in parms: - raise ValueError, "unsupported DTSTART parm: "+parm + if parm.startswith("TZID="): + try: + tzkey = TZID_NAMES[parm.split('TZID=')[-1]] + except KeyError: + continue + if tzids is None: + from . import tz + tzlookup = tz.gettz + elif callable(tzids): + tzlookup = tzids + else: + tzlookup = getattr(tzids, 'get', None) + if tzlookup is None: + msg = ('tzids must be a callable, ' + + 'mapping, or None, ' + + 'not %s' % tzids) + raise ValueError(msg) + + TZID = tzlookup(tzkey) + continue + if parm not in valid_values: + raise ValueError("unsupported DTSTART parm: "+parm) + else: + if value_found: + msg = ("Duplicate value parameter found in " + + "DTSTART: " + parm) + raise ValueError(msg) + value_found = True if not parser: from dateutil import parser dtstart = parser.parse(value, ignoretz=ignoretz, tzinfos=tzinfos) + if TZID is not None: + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=TZID) + else: + raise ValueError('DTSTART specifies multiple timezones') else: - raise ValueError, "unsupported property: "+name - if (forceset or len(rrulevals) > 1 or - rdatevals or exrulevals or exdatevals): + raise ValueError("unsupported property: "+name) + if (forceset or len(rrulevals) > 1 or rdatevals + or exrulevals or exdatevals): if not parser and (rdatevals or exdatevals): from dateutil import parser - set = rruleset(cache=cache) + rset = rruleset(cache=cache) for value in rrulevals: - set.rrule(self._parse_rfc_rrule(value, dtstart=dtstart, - ignoretz=ignoretz, - tzinfos=tzinfos)) - for value in rdatevals: - for datestr in value.split(','): - set.rdate(parser.parse(datestr, - ignoretz=ignoretz, - tzinfos=tzinfos)) - for value in exrulevals: - set.exrule(self._parse_rfc_rrule(value, dtstart=dtstart, + rset.rrule(self._parse_rfc_rrule(value, dtstart=dtstart, ignoretz=ignoretz, tzinfos=tzinfos)) - for value in exdatevals: + for value in rdatevals: for datestr in value.split(','): - set.exdate(parser.parse(datestr, + rset.rdate(parser.parse(datestr, ignoretz=ignoretz, tzinfos=tzinfos)) + for value in exrulevals: + rset.exrule(self._parse_rfc_rrule(value, dtstart=dtstart, + ignoretz=ignoretz, + tzinfos=tzinfos)) + for value in exdatevals: + for datestr in value.split(','): + rset.exdate(parser.parse(datestr, + ignoretz=ignoretz, + tzinfos=tzinfos)) if compatible and dtstart: - set.rdate(dtstart) - return set + rset.rdate(dtstart) + return rset else: return self._parse_rfc_rrule(rrulevals[0], dtstart=dtstart, @@ -1092,6 +1666,7 @@ class _rrulestr: def __call__(self, s, **kwargs): return self._parse_rfc(s, **kwargs) + rrulestr = _rrulestr() # vim:ts=4:sw=4:et diff --git a/libs/dateutil/tz.py b/libs/dateutil/tz.py deleted file mode 100644 index 0e28d6b3..00000000 --- a/libs/dateutil/tz.py +++ /dev/null @@ -1,951 +0,0 @@ -""" -Copyright (c) 2003-2007 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. -""" -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" - -import datetime -import struct -import time -import sys -import os - -relativedelta = None -parser = None -rrule = None - -__all__ = ["tzutc", "tzoffset", "tzlocal", "tzfile", "tzrange", - "tzstr", "tzical", "tzwin", "tzwinlocal", "gettz"] - -try: - from dateutil.tzwin import tzwin, tzwinlocal -except (ImportError, OSError): - tzwin, tzwinlocal = None, None - -ZERO = datetime.timedelta(0) -EPOCHORDINAL = datetime.datetime.utcfromtimestamp(0).toordinal() - -class tzutc(datetime.tzinfo): - - def utcoffset(self, dt): - return ZERO - - def dst(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def __eq__(self, other): - return (isinstance(other, tzutc) or - (isinstance(other, tzoffset) and other._offset == ZERO)) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s()" % self.__class__.__name__ - - __reduce__ = object.__reduce__ - -class tzoffset(datetime.tzinfo): - - def __init__(self, name, offset): - self._name = name - self._offset = datetime.timedelta(seconds=offset) - - def utcoffset(self, dt): - return self._offset - - def dst(self, dt): - return ZERO - - def tzname(self, dt): - return self._name - - def __eq__(self, other): - return (isinstance(other, tzoffset) and - self._offset == other._offset) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s(%s, %s)" % (self.__class__.__name__, - `self._name`, - self._offset.days*86400+self._offset.seconds) - - __reduce__ = object.__reduce__ - -class tzlocal(datetime.tzinfo): - - _std_offset = datetime.timedelta(seconds=-time.timezone) - if time.daylight: - _dst_offset = datetime.timedelta(seconds=-time.altzone) - else: - _dst_offset = _std_offset - - def utcoffset(self, dt): - if self._isdst(dt): - return self._dst_offset - else: - return self._std_offset - - def dst(self, dt): - if self._isdst(dt): - return self._dst_offset-self._std_offset - else: - return ZERO - - def tzname(self, dt): - return time.tzname[self._isdst(dt)] - - def _isdst(self, dt): - # We can't use mktime here. It is unstable when deciding if - # the hour near to a change is DST or not. - # - # timestamp = time.mktime((dt.year, dt.month, dt.day, dt.hour, - # dt.minute, dt.second, dt.weekday(), 0, -1)) - # return time.localtime(timestamp).tm_isdst - # - # The code above yields the following result: - # - #>>> import tz, datetime - #>>> t = tz.tzlocal() - #>>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() - #'BRDT' - #>>> datetime.datetime(2003,2,16,0,tzinfo=t).tzname() - #'BRST' - #>>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() - #'BRST' - #>>> datetime.datetime(2003,2,15,22,tzinfo=t).tzname() - #'BRDT' - #>>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() - #'BRDT' - # - # Here is a more stable implementation: - # - timestamp = ((dt.toordinal() - EPOCHORDINAL) * 86400 - + dt.hour * 3600 - + dt.minute * 60 - + dt.second) - return time.localtime(timestamp+time.timezone).tm_isdst - - def __eq__(self, other): - if not isinstance(other, tzlocal): - return False - return (self._std_offset == other._std_offset and - self._dst_offset == other._dst_offset) - return True - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s()" % self.__class__.__name__ - - __reduce__ = object.__reduce__ - -class _ttinfo(object): - __slots__ = ["offset", "delta", "isdst", "abbr", "isstd", "isgmt"] - - def __init__(self): - for attr in self.__slots__: - setattr(self, attr, None) - - def __repr__(self): - l = [] - for attr in self.__slots__: - value = getattr(self, attr) - if value is not None: - l.append("%s=%s" % (attr, `value`)) - return "%s(%s)" % (self.__class__.__name__, ", ".join(l)) - - def __eq__(self, other): - if not isinstance(other, _ttinfo): - return False - return (self.offset == other.offset and - self.delta == other.delta and - self.isdst == other.isdst and - self.abbr == other.abbr and - self.isstd == other.isstd and - self.isgmt == other.isgmt) - - def __ne__(self, other): - return not self.__eq__(other) - - def __getstate__(self): - state = {} - for name in self.__slots__: - state[name] = getattr(self, name, None) - return state - - def __setstate__(self, state): - for name in self.__slots__: - if name in state: - setattr(self, name, state[name]) - -class tzfile(datetime.tzinfo): - - # http://www.twinsun.com/tz/tz-link.htm - # ftp://elsie.nci.nih.gov/pub/tz*.tar.gz - - def __init__(self, fileobj): - if isinstance(fileobj, basestring): - self._filename = fileobj - fileobj = open(fileobj) - elif hasattr(fileobj, "name"): - self._filename = fileobj.name - else: - self._filename = `fileobj` - - # From tzfile(5): - # - # The time zone information files used by tzset(3) - # begin with the magic characters "TZif" to identify - # them as time zone information files, followed by - # sixteen bytes reserved for future use, followed by - # six four-byte values of type long, written in a - # ``standard'' byte order (the high-order byte - # of the value is written first). - - if fileobj.read(4) != "TZif": - raise ValueError, "magic not found" - - fileobj.read(16) - - ( - # The number of UTC/local indicators stored in the file. - ttisgmtcnt, - - # The number of standard/wall indicators stored in the file. - ttisstdcnt, - - # The number of leap seconds for which data is - # stored in the file. - leapcnt, - - # The number of "transition times" for which data - # is stored in the file. - timecnt, - - # The number of "local time types" for which data - # is stored in the file (must not be zero). - typecnt, - - # The number of characters of "time zone - # abbreviation strings" stored in the file. - charcnt, - - ) = struct.unpack(">6l", fileobj.read(24)) - - # The above header is followed by tzh_timecnt four-byte - # values of type long, sorted in ascending order. - # These values are written in ``standard'' byte order. - # Each is used as a transition time (as returned by - # time(2)) at which the rules for computing local time - # change. - - if timecnt: - self._trans_list = struct.unpack(">%dl" % timecnt, - fileobj.read(timecnt*4)) - else: - self._trans_list = [] - - # Next come tzh_timecnt one-byte values of type unsigned - # char; each one tells which of the different types of - # ``local time'' types described in the file is associated - # with the same-indexed transition time. These values - # serve as indices into an array of ttinfo structures that - # appears next in the file. - - if timecnt: - self._trans_idx = struct.unpack(">%dB" % timecnt, - fileobj.read(timecnt)) - else: - self._trans_idx = [] - - # Each ttinfo structure is written as a four-byte value - # for tt_gmtoff of type long, in a standard byte - # order, followed by a one-byte value for tt_isdst - # and a one-byte value for tt_abbrind. In each - # structure, tt_gmtoff gives the number of - # seconds to be added to UTC, tt_isdst tells whether - # tm_isdst should be set by localtime(3), and - # tt_abbrind serves as an index into the array of - # time zone abbreviation characters that follow the - # ttinfo structure(s) in the file. - - ttinfo = [] - - for i in range(typecnt): - ttinfo.append(struct.unpack(">lbb", fileobj.read(6))) - - abbr = fileobj.read(charcnt) - - # Then there are tzh_leapcnt pairs of four-byte - # values, written in standard byte order; the - # first value of each pair gives the time (as - # returned by time(2)) at which a leap second - # occurs; the second gives the total number of - # leap seconds to be applied after the given time. - # The pairs of values are sorted in ascending order - # by time. - - # Not used, for now - if leapcnt: - leap = struct.unpack(">%dl" % (leapcnt*2), - fileobj.read(leapcnt*8)) - - # Then there are tzh_ttisstdcnt standard/wall - # indicators, each stored as a one-byte value; - # they tell whether the transition times associated - # with local time types were specified as standard - # time or wall clock time, and are used when - # a time zone file is used in handling POSIX-style - # time zone environment variables. - - if ttisstdcnt: - isstd = struct.unpack(">%db" % ttisstdcnt, - fileobj.read(ttisstdcnt)) - - # Finally, there are tzh_ttisgmtcnt UTC/local - # indicators, each stored as a one-byte value; - # they tell whether the transition times associated - # with local time types were specified as UTC or - # local time, and are used when a time zone file - # is used in handling POSIX-style time zone envi- - # ronment variables. - - if ttisgmtcnt: - isgmt = struct.unpack(">%db" % ttisgmtcnt, - fileobj.read(ttisgmtcnt)) - - # ** Everything has been read ** - - # Build ttinfo list - self._ttinfo_list = [] - for i in range(typecnt): - gmtoff, isdst, abbrind = ttinfo[i] - # Round to full-minutes if that's not the case. Python's - # datetime doesn't accept sub-minute timezones. Check - # http://python.org/sf/1447945 for some information. - gmtoff = (gmtoff+30)//60*60 - tti = _ttinfo() - tti.offset = gmtoff - tti.delta = datetime.timedelta(seconds=gmtoff) - tti.isdst = isdst - tti.abbr = abbr[abbrind:abbr.find('\x00', abbrind)] - tti.isstd = (ttisstdcnt > i and isstd[i] != 0) - tti.isgmt = (ttisgmtcnt > i and isgmt[i] != 0) - self._ttinfo_list.append(tti) - - # Replace ttinfo indexes for ttinfo objects. - trans_idx = [] - for idx in self._trans_idx: - trans_idx.append(self._ttinfo_list[idx]) - self._trans_idx = tuple(trans_idx) - - # Set standard, dst, and before ttinfos. before will be - # used when a given time is before any transitions, - # and will be set to the first non-dst ttinfo, or to - # the first dst, if all of them are dst. - self._ttinfo_std = None - self._ttinfo_dst = None - self._ttinfo_before = None - if self._ttinfo_list: - if not self._trans_list: - self._ttinfo_std = self._ttinfo_first = self._ttinfo_list[0] - else: - for i in range(timecnt-1,-1,-1): - tti = self._trans_idx[i] - if not self._ttinfo_std and not tti.isdst: - self._ttinfo_std = tti - elif not self._ttinfo_dst and tti.isdst: - self._ttinfo_dst = tti - if self._ttinfo_std and self._ttinfo_dst: - break - else: - if self._ttinfo_dst and not self._ttinfo_std: - self._ttinfo_std = self._ttinfo_dst - - for tti in self._ttinfo_list: - if not tti.isdst: - self._ttinfo_before = tti - break - else: - self._ttinfo_before = self._ttinfo_list[0] - - # Now fix transition times to become relative to wall time. - # - # I'm not sure about this. In my tests, the tz source file - # is setup to wall time, and in the binary file isstd and - # isgmt are off, so it should be in wall time. OTOH, it's - # always in gmt time. Let me know if you have comments - # about this. - laststdoffset = 0 - self._trans_list = list(self._trans_list) - for i in range(len(self._trans_list)): - tti = self._trans_idx[i] - if not tti.isdst: - # This is std time. - self._trans_list[i] += tti.offset - laststdoffset = tti.offset - else: - # This is dst time. Convert to std. - self._trans_list[i] += laststdoffset - self._trans_list = tuple(self._trans_list) - - def _find_ttinfo(self, dt, laststd=0): - timestamp = ((dt.toordinal() - EPOCHORDINAL) * 86400 - + dt.hour * 3600 - + dt.minute * 60 - + dt.second) - idx = 0 - for trans in self._trans_list: - if timestamp < trans: - break - idx += 1 - else: - return self._ttinfo_std - if idx == 0: - return self._ttinfo_before - if laststd: - while idx > 0: - tti = self._trans_idx[idx-1] - if not tti.isdst: - return tti - idx -= 1 - else: - return self._ttinfo_std - else: - return self._trans_idx[idx-1] - - def utcoffset(self, dt): - if not self._ttinfo_std: - return ZERO - return self._find_ttinfo(dt).delta - - def dst(self, dt): - if not self._ttinfo_dst: - return ZERO - tti = self._find_ttinfo(dt) - if not tti.isdst: - return ZERO - - # The documentation says that utcoffset()-dst() must - # be constant for every dt. - return tti.delta-self._find_ttinfo(dt, laststd=1).delta - - # An alternative for that would be: - # - # return self._ttinfo_dst.offset-self._ttinfo_std.offset - # - # However, this class stores historical changes in the - # dst offset, so I belive that this wouldn't be the right - # way to implement this. - - def tzname(self, dt): - if not self._ttinfo_std: - return None - return self._find_ttinfo(dt).abbr - - def __eq__(self, other): - if not isinstance(other, tzfile): - return False - return (self._trans_list == other._trans_list and - self._trans_idx == other._trans_idx and - self._ttinfo_list == other._ttinfo_list) - - def __ne__(self, other): - return not self.__eq__(other) - - - def __repr__(self): - return "%s(%s)" % (self.__class__.__name__, `self._filename`) - - def __reduce__(self): - if not os.path.isfile(self._filename): - raise ValueError, "Unpickable %s class" % self.__class__.__name__ - return (self.__class__, (self._filename,)) - -class tzrange(datetime.tzinfo): - - def __init__(self, stdabbr, stdoffset=None, - dstabbr=None, dstoffset=None, - start=None, end=None): - global relativedelta - if not relativedelta: - from dateutil import relativedelta - self._std_abbr = stdabbr - self._dst_abbr = dstabbr - if stdoffset is not None: - self._std_offset = datetime.timedelta(seconds=stdoffset) - else: - self._std_offset = ZERO - if dstoffset is not None: - self._dst_offset = datetime.timedelta(seconds=dstoffset) - elif dstabbr and stdoffset is not None: - self._dst_offset = self._std_offset+datetime.timedelta(hours=+1) - else: - self._dst_offset = ZERO - if dstabbr and start is None: - self._start_delta = relativedelta.relativedelta( - hours=+2, month=4, day=1, weekday=relativedelta.SU(+1)) - else: - self._start_delta = start - if dstabbr and end is None: - self._end_delta = relativedelta.relativedelta( - hours=+1, month=10, day=31, weekday=relativedelta.SU(-1)) - else: - self._end_delta = end - - def utcoffset(self, dt): - if self._isdst(dt): - return self._dst_offset - else: - return self._std_offset - - def dst(self, dt): - if self._isdst(dt): - return self._dst_offset-self._std_offset - else: - return ZERO - - def tzname(self, dt): - if self._isdst(dt): - return self._dst_abbr - else: - return self._std_abbr - - def _isdst(self, dt): - if not self._start_delta: - return False - year = datetime.datetime(dt.year,1,1) - start = year+self._start_delta - end = year+self._end_delta - dt = dt.replace(tzinfo=None) - if start < end: - return dt >= start and dt < end - else: - return dt >= start or dt < end - - def __eq__(self, other): - if not isinstance(other, tzrange): - return False - return (self._std_abbr == other._std_abbr and - self._dst_abbr == other._dst_abbr and - self._std_offset == other._std_offset and - self._dst_offset == other._dst_offset and - self._start_delta == other._start_delta and - self._end_delta == other._end_delta) - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return "%s(...)" % self.__class__.__name__ - - __reduce__ = object.__reduce__ - -class tzstr(tzrange): - - def __init__(self, s): - global parser - if not parser: - from dateutil import parser - self._s = s - - res = parser._parsetz(s) - if res is None: - raise ValueError, "unknown string format" - - # Here we break the compatibility with the TZ variable handling. - # GMT-3 actually *means* the timezone -3. - if res.stdabbr in ("GMT", "UTC"): - res.stdoffset *= -1 - - # We must initialize it first, since _delta() needs - # _std_offset and _dst_offset set. Use False in start/end - # to avoid building it two times. - tzrange.__init__(self, res.stdabbr, res.stdoffset, - res.dstabbr, res.dstoffset, - start=False, end=False) - - if not res.dstabbr: - self._start_delta = None - self._end_delta = None - else: - self._start_delta = self._delta(res.start) - if self._start_delta: - self._end_delta = self._delta(res.end, isend=1) - - def _delta(self, x, isend=0): - kwargs = {} - if x.month is not None: - kwargs["month"] = x.month - if x.weekday is not None: - kwargs["weekday"] = relativedelta.weekday(x.weekday, x.week) - if x.week > 0: - kwargs["day"] = 1 - else: - kwargs["day"] = 31 - elif x.day: - kwargs["day"] = x.day - elif x.yday is not None: - kwargs["yearday"] = x.yday - elif x.jyday is not None: - kwargs["nlyearday"] = x.jyday - if not kwargs: - # Default is to start on first sunday of april, and end - # on last sunday of october. - if not isend: - kwargs["month"] = 4 - kwargs["day"] = 1 - kwargs["weekday"] = relativedelta.SU(+1) - else: - kwargs["month"] = 10 - kwargs["day"] = 31 - kwargs["weekday"] = relativedelta.SU(-1) - if x.time is not None: - kwargs["seconds"] = x.time - else: - # Default is 2AM. - kwargs["seconds"] = 7200 - if isend: - # Convert to standard time, to follow the documented way - # of working with the extra hour. See the documentation - # of the tzinfo class. - delta = self._dst_offset-self._std_offset - kwargs["seconds"] -= delta.seconds+delta.days*86400 - return relativedelta.relativedelta(**kwargs) - - def __repr__(self): - return "%s(%s)" % (self.__class__.__name__, `self._s`) - -class _tzicalvtzcomp: - def __init__(self, tzoffsetfrom, tzoffsetto, isdst, - tzname=None, rrule=None): - self.tzoffsetfrom = datetime.timedelta(seconds=tzoffsetfrom) - self.tzoffsetto = datetime.timedelta(seconds=tzoffsetto) - self.tzoffsetdiff = self.tzoffsetto-self.tzoffsetfrom - self.isdst = isdst - self.tzname = tzname - self.rrule = rrule - -class _tzicalvtz(datetime.tzinfo): - def __init__(self, tzid, comps=[]): - self._tzid = tzid - self._comps = comps - self._cachedate = [] - self._cachecomp = [] - - def _find_comp(self, dt): - if len(self._comps) == 1: - return self._comps[0] - dt = dt.replace(tzinfo=None) - try: - return self._cachecomp[self._cachedate.index(dt)] - except ValueError: - pass - lastcomp = None - lastcompdt = None - for comp in self._comps: - if not comp.isdst: - # Handle the extra hour in DST -> STD - compdt = comp.rrule.before(dt-comp.tzoffsetdiff, inc=True) - else: - compdt = comp.rrule.before(dt, inc=True) - if compdt and (not lastcompdt or lastcompdt < compdt): - lastcompdt = compdt - lastcomp = comp - if not lastcomp: - # RFC says nothing about what to do when a given - # time is before the first onset date. We'll look for the - # first standard component, or the first component, if - # none is found. - for comp in self._comps: - if not comp.isdst: - lastcomp = comp - break - else: - lastcomp = comp[0] - self._cachedate.insert(0, dt) - self._cachecomp.insert(0, lastcomp) - if len(self._cachedate) > 10: - self._cachedate.pop() - self._cachecomp.pop() - return lastcomp - - def utcoffset(self, dt): - return self._find_comp(dt).tzoffsetto - - def dst(self, dt): - comp = self._find_comp(dt) - if comp.isdst: - return comp.tzoffsetdiff - else: - return ZERO - - def tzname(self, dt): - return self._find_comp(dt).tzname - - def __repr__(self): - return "" % `self._tzid` - - __reduce__ = object.__reduce__ - -class tzical: - def __init__(self, fileobj): - global rrule - if not rrule: - from dateutil import rrule - - if isinstance(fileobj, basestring): - self._s = fileobj - fileobj = open(fileobj) - elif hasattr(fileobj, "name"): - self._s = fileobj.name - else: - self._s = `fileobj` - - self._vtz = {} - - self._parse_rfc(fileobj.read()) - - def keys(self): - return self._vtz.keys() - - def get(self, tzid=None): - if tzid is None: - keys = self._vtz.keys() - if len(keys) == 0: - raise ValueError, "no timezones defined" - elif len(keys) > 1: - raise ValueError, "more than one timezone available" - tzid = keys[0] - return self._vtz.get(tzid) - - def _parse_offset(self, s): - s = s.strip() - if not s: - raise ValueError, "empty offset" - if s[0] in ('+', '-'): - signal = (-1,+1)[s[0]=='+'] - s = s[1:] - else: - signal = +1 - if len(s) == 4: - return (int(s[:2])*3600+int(s[2:])*60)*signal - elif len(s) == 6: - return (int(s[:2])*3600+int(s[2:4])*60+int(s[4:]))*signal - else: - raise ValueError, "invalid offset: "+s - - def _parse_rfc(self, s): - lines = s.splitlines() - if not lines: - raise ValueError, "empty string" - - # Unfold - i = 0 - while i < len(lines): - line = lines[i].rstrip() - if not line: - del lines[i] - elif i > 0 and line[0] == " ": - lines[i-1] += line[1:] - del lines[i] - else: - i += 1 - - tzid = None - comps = [] - invtz = False - comptype = None - for line in lines: - if not line: - continue - name, value = line.split(':', 1) - parms = name.split(';') - if not parms: - raise ValueError, "empty property name" - name = parms[0].upper() - parms = parms[1:] - if invtz: - if name == "BEGIN": - if value in ("STANDARD", "DAYLIGHT"): - # Process component - pass - else: - raise ValueError, "unknown component: "+value - comptype = value - founddtstart = False - tzoffsetfrom = None - tzoffsetto = None - rrulelines = [] - tzname = None - elif name == "END": - if value == "VTIMEZONE": - if comptype: - raise ValueError, \ - "component not closed: "+comptype - if not tzid: - raise ValueError, \ - "mandatory TZID not found" - if not comps: - raise ValueError, \ - "at least one component is needed" - # Process vtimezone - self._vtz[tzid] = _tzicalvtz(tzid, comps) - invtz = False - elif value == comptype: - if not founddtstart: - raise ValueError, \ - "mandatory DTSTART not found" - if tzoffsetfrom is None: - raise ValueError, \ - "mandatory TZOFFSETFROM not found" - if tzoffsetto is None: - raise ValueError, \ - "mandatory TZOFFSETFROM not found" - # Process component - rr = None - if rrulelines: - rr = rrule.rrulestr("\n".join(rrulelines), - compatible=True, - ignoretz=True, - cache=True) - comp = _tzicalvtzcomp(tzoffsetfrom, tzoffsetto, - (comptype == "DAYLIGHT"), - tzname, rr) - comps.append(comp) - comptype = None - else: - raise ValueError, \ - "invalid component end: "+value - elif comptype: - if name == "DTSTART": - rrulelines.append(line) - founddtstart = True - elif name in ("RRULE", "RDATE", "EXRULE", "EXDATE"): - rrulelines.append(line) - elif name == "TZOFFSETFROM": - if parms: - raise ValueError, \ - "unsupported %s parm: %s "%(name, parms[0]) - tzoffsetfrom = self._parse_offset(value) - elif name == "TZOFFSETTO": - if parms: - raise ValueError, \ - "unsupported TZOFFSETTO parm: "+parms[0] - tzoffsetto = self._parse_offset(value) - elif name == "TZNAME": - if parms: - raise ValueError, \ - "unsupported TZNAME parm: "+parms[0] - tzname = value - elif name == "COMMENT": - pass - else: - raise ValueError, "unsupported property: "+name - else: - if name == "TZID": - if parms: - raise ValueError, \ - "unsupported TZID parm: "+parms[0] - tzid = value - elif name in ("TZURL", "LAST-MODIFIED", "COMMENT"): - pass - else: - raise ValueError, "unsupported property: "+name - elif name == "BEGIN" and value == "VTIMEZONE": - tzid = None - comps = [] - invtz = True - - def __repr__(self): - return "%s(%s)" % (self.__class__.__name__, `self._s`) - -if sys.platform != "win32": - TZFILES = ["/etc/localtime", "localtime"] - TZPATHS = ["/usr/share/zoneinfo", "/usr/lib/zoneinfo", "/etc/zoneinfo"] -else: - TZFILES = [] - TZPATHS = [] - -def gettz(name=None): - tz = None - if not name: - try: - name = os.environ["TZ"] - except KeyError: - pass - if name is None or name == ":": - for filepath in TZFILES: - if not os.path.isabs(filepath): - filename = filepath - for path in TZPATHS: - filepath = os.path.join(path, filename) - if os.path.isfile(filepath): - break - else: - continue - if os.path.isfile(filepath): - try: - tz = tzfile(filepath) - break - except (IOError, OSError, ValueError): - pass - else: - tz = tzlocal() - else: - if name.startswith(":"): - name = name[:-1] - if os.path.isabs(name): - if os.path.isfile(name): - tz = tzfile(name) - else: - tz = None - else: - for path in TZPATHS: - filepath = os.path.join(path, name) - if not os.path.isfile(filepath): - filepath = filepath.replace(' ','_') - if not os.path.isfile(filepath): - continue - try: - tz = tzfile(filepath) - break - except (IOError, OSError, ValueError): - pass - else: - tz = None - if tzwin: - try: - tz = tzwin(name) - except OSError: - pass - if not tz: - from dateutil.zoneinfo import gettz - tz = gettz(name) - if not tz: - for c in name: - # name must have at least one offset to be a tzstr - if c in "0123456789": - try: - tz = tzstr(name) - except ValueError: - pass - break - else: - if name in ("GMT", "UTC"): - tz = tzutc() - elif name in time.tzname: - tz = tzlocal() - return tz - -# vim:ts=4:sw=4:et diff --git a/libs/dateutil/tz/__init__.py b/libs/dateutil/tz/__init__.py new file mode 100644 index 00000000..5a2d9cd6 --- /dev/null +++ b/libs/dateutil/tz/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from .tz import * +from .tz import __doc__ + +#: Convenience constant providing a :class:`tzutc()` instance +#: +#: .. versionadded:: 2.7.0 +UTC = tzutc() + +__all__ = ["tzutc", "tzoffset", "tzlocal", "tzfile", "tzrange", + "tzstr", "tzical", "tzwin", "tzwinlocal", "gettz", + "enfold", "datetime_ambiguous", "datetime_exists", + "resolve_imaginary", "UTC", "DeprecatedTzFormatWarning"] + + +class DeprecatedTzFormatWarning(Warning): + """Warning raised when time zones are parsed from deprecated formats.""" diff --git a/libs/dateutil/tz/_common.py b/libs/dateutil/tz/_common.py new file mode 100644 index 00000000..ccabb7da --- /dev/null +++ b/libs/dateutil/tz/_common.py @@ -0,0 +1,415 @@ +from six import PY3 + +from functools import wraps + +from datetime import datetime, timedelta, tzinfo + + +ZERO = timedelta(0) + +__all__ = ['tzname_in_python2', 'enfold'] + + +def tzname_in_python2(namefunc): + """Change unicode output into bytestrings in Python 2 + + tzname() API changed in Python 3. It used to return bytes, but was changed + to unicode strings + """ + def adjust_encoding(*args, **kwargs): + name = namefunc(*args, **kwargs) + if name is not None and not PY3: + name = name.encode() + + return name + + return adjust_encoding + + +# The following is adapted from Alexander Belopolsky's tz library +# https://github.com/abalkin/tz +if hasattr(datetime, 'fold'): + # This is the pre-python 3.6 fold situation + def enfold(dt, fold=1): + """ + Provides a unified interface for assigning the ``fold`` attribute to + datetimes both before and after the implementation of PEP-495. + + :param fold: + The value for the ``fold`` attribute in the returned datetime. This + should be either 0 or 1. + + :return: + Returns an object for which ``getattr(dt, 'fold', 0)`` returns + ``fold`` for all versions of Python. In versions prior to + Python 3.6, this is a ``_DatetimeWithFold`` object, which is a + subclass of :py:class:`datetime.datetime` with the ``fold`` + attribute added, if ``fold`` is 1. + + .. versionadded:: 2.6.0 + """ + return dt.replace(fold=fold) + +else: + class _DatetimeWithFold(datetime): + """ + This is a class designed to provide a PEP 495-compliant interface for + Python versions before 3.6. It is used only for dates in a fold, so + the ``fold`` attribute is fixed at ``1``. + + .. versionadded:: 2.6.0 + """ + __slots__ = () + + def replace(self, *args, **kwargs): + """ + Return a datetime with the same attributes, except for those + attributes given new values by whichever keyword arguments are + specified. Note that tzinfo=None can be specified to create a naive + datetime from an aware datetime with no conversion of date and time + data. + + This is reimplemented in ``_DatetimeWithFold`` because pypy3 will + return a ``datetime.datetime`` even if ``fold`` is unchanged. + """ + argnames = ( + 'year', 'month', 'day', 'hour', 'minute', 'second', + 'microsecond', 'tzinfo' + ) + + for arg, argname in zip(args, argnames): + if argname in kwargs: + raise TypeError('Duplicate argument: {}'.format(argname)) + + kwargs[argname] = arg + + for argname in argnames: + if argname not in kwargs: + kwargs[argname] = getattr(self, argname) + + dt_class = self.__class__ if kwargs.get('fold', 1) else datetime + + return dt_class(**kwargs) + + @property + def fold(self): + return 1 + + def enfold(dt, fold=1): + """ + Provides a unified interface for assigning the ``fold`` attribute to + datetimes both before and after the implementation of PEP-495. + + :param fold: + The value for the ``fold`` attribute in the returned datetime. This + should be either 0 or 1. + + :return: + Returns an object for which ``getattr(dt, 'fold', 0)`` returns + ``fold`` for all versions of Python. In versions prior to + Python 3.6, this is a ``_DatetimeWithFold`` object, which is a + subclass of :py:class:`datetime.datetime` with the ``fold`` + attribute added, if ``fold`` is 1. + + .. versionadded:: 2.6.0 + """ + if getattr(dt, 'fold', 0) == fold: + return dt + + args = dt.timetuple()[:6] + args += (dt.microsecond, dt.tzinfo) + + if fold: + return _DatetimeWithFold(*args) + else: + return datetime(*args) + + +def _validate_fromutc_inputs(f): + """ + The CPython version of ``fromutc`` checks that the input is a ``datetime`` + object and that ``self`` is attached as its ``tzinfo``. + """ + @wraps(f) + def fromutc(self, dt): + if not isinstance(dt, datetime): + raise TypeError("fromutc() requires a datetime argument") + if dt.tzinfo is not self: + raise ValueError("dt.tzinfo is not self") + + return f(self, dt) + + return fromutc + + +class _tzinfo(tzinfo): + """ + Base class for all ``dateutil`` ``tzinfo`` objects. + """ + + def is_ambiguous(self, dt): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + + + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + + dt = dt.replace(tzinfo=self) + + wall_0 = enfold(dt, fold=0) + wall_1 = enfold(dt, fold=1) + + same_offset = wall_0.utcoffset() == wall_1.utcoffset() + same_dt = wall_0.replace(tzinfo=None) == wall_1.replace(tzinfo=None) + + return same_dt and not same_offset + + def _fold_status(self, dt_utc, dt_wall): + """ + Determine the fold status of a "wall" datetime, given a representation + of the same datetime as a (naive) UTC datetime. This is calculated based + on the assumption that ``dt.utcoffset() - dt.dst()`` is constant for all + datetimes, and that this offset is the actual number of hours separating + ``dt_utc`` and ``dt_wall``. + + :param dt_utc: + Representation of the datetime as UTC + + :param dt_wall: + Representation of the datetime as "wall time". This parameter must + either have a `fold` attribute or have a fold-naive + :class:`datetime.tzinfo` attached, otherwise the calculation may + fail. + """ + if self.is_ambiguous(dt_wall): + delta_wall = dt_wall - dt_utc + _fold = int(delta_wall == (dt_utc.utcoffset() - dt_utc.dst())) + else: + _fold = 0 + + return _fold + + def _fold(self, dt): + return getattr(dt, 'fold', 0) + + def _fromutc(self, dt): + """ + Given a timezone-aware datetime in a given timezone, calculates a + timezone-aware datetime in a new timezone. + + Since this is the one time that we *know* we have an unambiguous + datetime object, we take this opportunity to determine whether the + datetime is ambiguous and in a "fold" state (e.g. if it's the first + occurence, chronologically, of the ambiguous datetime). + + :param dt: + A timezone-aware :class:`datetime.datetime` object. + """ + + # Re-implement the algorithm from Python's datetime.py + dtoff = dt.utcoffset() + if dtoff is None: + raise ValueError("fromutc() requires a non-None utcoffset() " + "result") + + # The original datetime.py code assumes that `dst()` defaults to + # zero during ambiguous times. PEP 495 inverts this presumption, so + # for pre-PEP 495 versions of python, we need to tweak the algorithm. + dtdst = dt.dst() + if dtdst is None: + raise ValueError("fromutc() requires a non-None dst() result") + delta = dtoff - dtdst + + dt += delta + # Set fold=1 so we can default to being in the fold for + # ambiguous dates. + dtdst = enfold(dt, fold=1).dst() + if dtdst is None: + raise ValueError("fromutc(): dt.dst gave inconsistent " + "results; cannot convert") + return dt + dtdst + + @_validate_fromutc_inputs + def fromutc(self, dt): + """ + Given a timezone-aware datetime in a given timezone, calculates a + timezone-aware datetime in a new timezone. + + Since this is the one time that we *know* we have an unambiguous + datetime object, we take this opportunity to determine whether the + datetime is ambiguous and in a "fold" state (e.g. if it's the first + occurance, chronologically, of the ambiguous datetime). + + :param dt: + A timezone-aware :class:`datetime.datetime` object. + """ + dt_wall = self._fromutc(dt) + + # Calculate the fold status given the two datetimes. + _fold = self._fold_status(dt, dt_wall) + + # Set the default fold value for ambiguous dates + return enfold(dt_wall, fold=_fold) + + +class tzrangebase(_tzinfo): + """ + This is an abstract base class for time zones represented by an annual + transition into and out of DST. Child classes should implement the following + methods: + + * ``__init__(self, *args, **kwargs)`` + * ``transitions(self, year)`` - this is expected to return a tuple of + datetimes representing the DST on and off transitions in standard + time. + + A fully initialized ``tzrangebase`` subclass should also provide the + following attributes: + * ``hasdst``: Boolean whether or not the zone uses DST. + * ``_dst_offset`` / ``_std_offset``: :class:`datetime.timedelta` objects + representing the respective UTC offsets. + * ``_dst_abbr`` / ``_std_abbr``: Strings representing the timezone short + abbreviations in DST and STD, respectively. + * ``_hasdst``: Whether or not the zone has DST. + + .. versionadded:: 2.6.0 + """ + def __init__(self): + raise NotImplementedError('tzrangebase is an abstract base class') + + def utcoffset(self, dt): + isdst = self._isdst(dt) + + if isdst is None: + return None + elif isdst: + return self._dst_offset + else: + return self._std_offset + + def dst(self, dt): + isdst = self._isdst(dt) + + if isdst is None: + return None + elif isdst: + return self._dst_base_offset + else: + return ZERO + + @tzname_in_python2 + def tzname(self, dt): + if self._isdst(dt): + return self._dst_abbr + else: + return self._std_abbr + + def fromutc(self, dt): + """ Given a datetime in UTC, return local time """ + if not isinstance(dt, datetime): + raise TypeError("fromutc() requires a datetime argument") + + if dt.tzinfo is not self: + raise ValueError("dt.tzinfo is not self") + + # Get transitions - if there are none, fixed offset + transitions = self.transitions(dt.year) + if transitions is None: + return dt + self.utcoffset(dt) + + # Get the transition times in UTC + dston, dstoff = transitions + + dston -= self._std_offset + dstoff -= self._std_offset + + utc_transitions = (dston, dstoff) + dt_utc = dt.replace(tzinfo=None) + + isdst = self._naive_isdst(dt_utc, utc_transitions) + + if isdst: + dt_wall = dt + self._dst_offset + else: + dt_wall = dt + self._std_offset + + _fold = int(not isdst and self.is_ambiguous(dt_wall)) + + return enfold(dt_wall, fold=_fold) + + def is_ambiguous(self, dt): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + + + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + if not self.hasdst: + return False + + start, end = self.transitions(dt.year) + + dt = dt.replace(tzinfo=None) + return (end <= dt < end + self._dst_base_offset) + + def _isdst(self, dt): + if not self.hasdst: + return False + elif dt is None: + return None + + transitions = self.transitions(dt.year) + + if transitions is None: + return False + + dt = dt.replace(tzinfo=None) + + isdst = self._naive_isdst(dt, transitions) + + # Handle ambiguous dates + if not isdst and self.is_ambiguous(dt): + return not self._fold(dt) + else: + return isdst + + def _naive_isdst(self, dt, transitions): + dston, dstoff = transitions + + dt = dt.replace(tzinfo=None) + + if dston < dstoff: + isdst = dston <= dt < dstoff + else: + isdst = not dstoff <= dt < dston + + return isdst + + @property + def _dst_base_offset(self): + return self._dst_offset - self._std_offset + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(...)" % self.__class__.__name__ + + __reduce__ = object.__reduce__ diff --git a/libs/dateutil/tz/_factories.py b/libs/dateutil/tz/_factories.py new file mode 100644 index 00000000..de2e0c1d --- /dev/null +++ b/libs/dateutil/tz/_factories.py @@ -0,0 +1,49 @@ +from datetime import timedelta + + +class _TzSingleton(type): + def __init__(cls, *args, **kwargs): + cls.__instance = None + super(_TzSingleton, cls).__init__(*args, **kwargs) + + def __call__(cls): + if cls.__instance is None: + cls.__instance = super(_TzSingleton, cls).__call__() + return cls.__instance + +class _TzFactory(type): + def instance(cls, *args, **kwargs): + """Alternate constructor that returns a fresh instance""" + return type.__call__(cls, *args, **kwargs) + + +class _TzOffsetFactory(_TzFactory): + def __init__(cls, *args, **kwargs): + cls.__instances = {} + + def __call__(cls, name, offset): + if isinstance(offset, timedelta): + key = (name, offset.total_seconds()) + else: + key = (name, offset) + + instance = cls.__instances.get(key, None) + if instance is None: + instance = cls.__instances.setdefault(key, + cls.instance(name, offset)) + return instance + + +class _TzStrFactory(_TzFactory): + def __init__(cls, *args, **kwargs): + cls.__instances = {} + + def __call__(cls, s, posix_offset=False): + key = (s, posix_offset) + instance = cls.__instances.get(key, None) + + if instance is None: + instance = cls.__instances.setdefault(key, + cls.instance(s, posix_offset)) + return instance + diff --git a/libs/dateutil/tz/tz.py b/libs/dateutil/tz/tz.py new file mode 100644 index 00000000..ac82b9c8 --- /dev/null +++ b/libs/dateutil/tz/tz.py @@ -0,0 +1,1785 @@ +# -*- coding: utf-8 -*- +""" +This module offers timezone implementations subclassing the abstract +:py:class:`datetime.tzinfo` type. There are classes to handle tzfile format +files (usually are in :file:`/etc/localtime`, :file:`/usr/share/zoneinfo`, +etc), TZ environment string (in all known formats), given ranges (with help +from relative deltas), local machine timezone, fixed offset timezone, and UTC +timezone. +""" +import datetime +import struct +import time +import sys +import os +import bisect + +import six +from six import string_types +from six.moves import _thread +from ._common import tzname_in_python2, _tzinfo +from ._common import tzrangebase, enfold +from ._common import _validate_fromutc_inputs + +from ._factories import _TzSingleton, _TzOffsetFactory +from ._factories import _TzStrFactory +try: + from .win import tzwin, tzwinlocal +except ImportError: + tzwin = tzwinlocal = None + +ZERO = datetime.timedelta(0) +EPOCH = datetime.datetime.utcfromtimestamp(0) +EPOCHORDINAL = EPOCH.toordinal() + + +@six.add_metaclass(_TzSingleton) +class tzutc(datetime.tzinfo): + """ + This is a tzinfo object that represents the UTC time zone. + + **Examples:** + + .. doctest:: + + >>> from datetime import * + >>> from dateutil.tz import * + + >>> datetime.now() + datetime.datetime(2003, 9, 27, 9, 40, 1, 521290) + + >>> datetime.now(tzutc()) + datetime.datetime(2003, 9, 27, 12, 40, 12, 156379, tzinfo=tzutc()) + + >>> datetime.now(tzutc()).tzname() + 'UTC' + + .. versionchanged:: 2.7.0 + ``tzutc()`` is now a singleton, so the result of ``tzutc()`` will + always return the same object. + + .. doctest:: + + >>> from dateutil.tz import tzutc, UTC + >>> tzutc() is tzutc() + True + >>> tzutc() is UTC + True + """ + def utcoffset(self, dt): + return ZERO + + def dst(self, dt): + return ZERO + + @tzname_in_python2 + def tzname(self, dt): + return "UTC" + + def is_ambiguous(self, dt): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + + + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + return False + + @_validate_fromutc_inputs + def fromutc(self, dt): + """ + Fast track version of fromutc() returns the original ``dt`` object for + any valid :py:class:`datetime.datetime` object. + """ + return dt + + def __eq__(self, other): + if not isinstance(other, (tzutc, tzoffset)): + return NotImplemented + + return (isinstance(other, tzutc) or + (isinstance(other, tzoffset) and other._offset == ZERO)) + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s()" % self.__class__.__name__ + + __reduce__ = object.__reduce__ + + +@six.add_metaclass(_TzOffsetFactory) +class tzoffset(datetime.tzinfo): + """ + A simple class for representing a fixed offset from UTC. + + :param name: + The timezone name, to be returned when ``tzname()`` is called. + :param offset: + The time zone offset in seconds, or (since version 2.6.0, represented + as a :py:class:`datetime.timedelta` object). + """ + def __init__(self, name, offset): + self._name = name + + try: + # Allow a timedelta + offset = offset.total_seconds() + except (TypeError, AttributeError): + pass + self._offset = datetime.timedelta(seconds=offset) + + def utcoffset(self, dt): + return self._offset + + def dst(self, dt): + return ZERO + + @tzname_in_python2 + def tzname(self, dt): + return self._name + + @_validate_fromutc_inputs + def fromutc(self, dt): + return dt + self._offset + + def is_ambiguous(self, dt): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + return False + + def __eq__(self, other): + if not isinstance(other, tzoffset): + return NotImplemented + + return self._offset == other._offset + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%s, %s)" % (self.__class__.__name__, + repr(self._name), + int(self._offset.total_seconds())) + + __reduce__ = object.__reduce__ + + +class tzlocal(_tzinfo): + """ + A :class:`tzinfo` subclass built around the ``time`` timezone functions. + """ + def __init__(self): + super(tzlocal, self).__init__() + + self._std_offset = datetime.timedelta(seconds=-time.timezone) + if time.daylight: + self._dst_offset = datetime.timedelta(seconds=-time.altzone) + else: + self._dst_offset = self._std_offset + + self._dst_saved = self._dst_offset - self._std_offset + self._hasdst = bool(self._dst_saved) + self._tznames = tuple(time.tzname) + + def utcoffset(self, dt): + if dt is None and self._hasdst: + return None + + if self._isdst(dt): + return self._dst_offset + else: + return self._std_offset + + def dst(self, dt): + if dt is None and self._hasdst: + return None + + if self._isdst(dt): + return self._dst_offset - self._std_offset + else: + return ZERO + + @tzname_in_python2 + def tzname(self, dt): + return self._tznames[self._isdst(dt)] + + def is_ambiguous(self, dt): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + + + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + naive_dst = self._naive_is_dst(dt) + return (not naive_dst and + (naive_dst != self._naive_is_dst(dt - self._dst_saved))) + + def _naive_is_dst(self, dt): + timestamp = _datetime_to_timestamp(dt) + return time.localtime(timestamp + time.timezone).tm_isdst + + def _isdst(self, dt, fold_naive=True): + # We can't use mktime here. It is unstable when deciding if + # the hour near to a change is DST or not. + # + # timestamp = time.mktime((dt.year, dt.month, dt.day, dt.hour, + # dt.minute, dt.second, dt.weekday(), 0, -1)) + # return time.localtime(timestamp).tm_isdst + # + # The code above yields the following result: + # + # >>> import tz, datetime + # >>> t = tz.tzlocal() + # >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() + # 'BRDT' + # >>> datetime.datetime(2003,2,16,0,tzinfo=t).tzname() + # 'BRST' + # >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() + # 'BRST' + # >>> datetime.datetime(2003,2,15,22,tzinfo=t).tzname() + # 'BRDT' + # >>> datetime.datetime(2003,2,15,23,tzinfo=t).tzname() + # 'BRDT' + # + # Here is a more stable implementation: + # + if not self._hasdst: + return False + + # Check for ambiguous times: + dstval = self._naive_is_dst(dt) + fold = getattr(dt, 'fold', None) + + if self.is_ambiguous(dt): + if fold is not None: + return not self._fold(dt) + else: + return True + + return dstval + + def __eq__(self, other): + if isinstance(other, tzlocal): + return (self._std_offset == other._std_offset and + self._dst_offset == other._dst_offset) + elif isinstance(other, tzutc): + return (not self._hasdst and + self._tznames[0] in {'UTC', 'GMT'} and + self._std_offset == ZERO) + elif isinstance(other, tzoffset): + return (not self._hasdst and + self._tznames[0] == other._name and + self._std_offset == other._offset) + else: + return NotImplemented + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s()" % self.__class__.__name__ + + __reduce__ = object.__reduce__ + + +class _ttinfo(object): + __slots__ = ["offset", "delta", "isdst", "abbr", + "isstd", "isgmt", "dstoffset"] + + def __init__(self): + for attr in self.__slots__: + setattr(self, attr, None) + + def __repr__(self): + l = [] + for attr in self.__slots__: + value = getattr(self, attr) + if value is not None: + l.append("%s=%s" % (attr, repr(value))) + return "%s(%s)" % (self.__class__.__name__, ", ".join(l)) + + def __eq__(self, other): + if not isinstance(other, _ttinfo): + return NotImplemented + + return (self.offset == other.offset and + self.delta == other.delta and + self.isdst == other.isdst and + self.abbr == other.abbr and + self.isstd == other.isstd and + self.isgmt == other.isgmt and + self.dstoffset == other.dstoffset) + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __getstate__(self): + state = {} + for name in self.__slots__: + state[name] = getattr(self, name, None) + return state + + def __setstate__(self, state): + for name in self.__slots__: + if name in state: + setattr(self, name, state[name]) + + +class _tzfile(object): + """ + Lightweight class for holding the relevant transition and time zone + information read from binary tzfiles. + """ + attrs = ['trans_list', 'trans_list_utc', 'trans_idx', 'ttinfo_list', + 'ttinfo_std', 'ttinfo_dst', 'ttinfo_before', 'ttinfo_first'] + + def __init__(self, **kwargs): + for attr in self.attrs: + setattr(self, attr, kwargs.get(attr, None)) + + +class tzfile(_tzinfo): + """ + This is a ``tzinfo`` subclass thant allows one to use the ``tzfile(5)`` + format timezone files to extract current and historical zone information. + + :param fileobj: + This can be an opened file stream or a file name that the time zone + information can be read from. + + :param filename: + This is an optional parameter specifying the source of the time zone + information in the event that ``fileobj`` is a file object. If omitted + and ``fileobj`` is a file stream, this parameter will be set either to + ``fileobj``'s ``name`` attribute or to ``repr(fileobj)``. + + See `Sources for Time Zone and Daylight Saving Time Data + `_ for more information. + Time zone files can be compiled from the `IANA Time Zone database files + `_ with the `zic time zone compiler + `_ + + .. note:: + + Only construct a ``tzfile`` directly if you have a specific timezone + file on disk that you want to read into a Python ``tzinfo`` object. + If you want to get a ``tzfile`` representing a specific IANA zone, + (e.g. ``'America/New_York'``), you should call + :func:`dateutil.tz.gettz` with the zone identifier. + + + **Examples:** + + Using the US Eastern time zone as an example, we can see that a ``tzfile`` + provides time zone information for the standard Daylight Saving offsets: + + .. testsetup:: tzfile + + from dateutil.tz import gettz + from datetime import datetime + + .. doctest:: tzfile + + >>> NYC = gettz('America/New_York') + >>> NYC + tzfile('/usr/share/zoneinfo/America/New_York') + + >>> print(datetime(2016, 1, 3, tzinfo=NYC)) # EST + 2016-01-03 00:00:00-05:00 + + >>> print(datetime(2016, 7, 7, tzinfo=NYC)) # EDT + 2016-07-07 00:00:00-04:00 + + + The ``tzfile`` structure contains a fully history of the time zone, + so historical dates will also have the right offsets. For example, before + the adoption of the UTC standards, New York used local solar mean time: + + .. doctest:: tzfile + + >>> print(datetime(1901, 4, 12, tzinfo=NYC)) # LMT + 1901-04-12 00:00:00-04:56 + + And during World War II, New York was on "Eastern War Time", which was a + state of permanent daylight saving time: + + .. doctest:: tzfile + + >>> print(datetime(1944, 2, 7, tzinfo=NYC)) # EWT + 1944-02-07 00:00:00-04:00 + + """ + + def __init__(self, fileobj, filename=None): + super(tzfile, self).__init__() + + file_opened_here = False + if isinstance(fileobj, string_types): + self._filename = fileobj + fileobj = open(fileobj, 'rb') + file_opened_here = True + elif filename is not None: + self._filename = filename + elif hasattr(fileobj, "name"): + self._filename = fileobj.name + else: + self._filename = repr(fileobj) + + if fileobj is not None: + if not file_opened_here: + fileobj = _ContextWrapper(fileobj) + + with fileobj as file_stream: + tzobj = self._read_tzfile(file_stream) + + self._set_tzdata(tzobj) + + def _set_tzdata(self, tzobj): + """ Set the time zone data of this object from a _tzfile object """ + # Copy the relevant attributes over as private attributes + for attr in _tzfile.attrs: + setattr(self, '_' + attr, getattr(tzobj, attr)) + + def _read_tzfile(self, fileobj): + out = _tzfile() + + # From tzfile(5): + # + # The time zone information files used by tzset(3) + # begin with the magic characters "TZif" to identify + # them as time zone information files, followed by + # sixteen bytes reserved for future use, followed by + # six four-byte values of type long, written in a + # ``standard'' byte order (the high-order byte + # of the value is written first). + if fileobj.read(4).decode() != "TZif": + raise ValueError("magic not found") + + fileobj.read(16) + + ( + # The number of UTC/local indicators stored in the file. + ttisgmtcnt, + + # The number of standard/wall indicators stored in the file. + ttisstdcnt, + + # The number of leap seconds for which data is + # stored in the file. + leapcnt, + + # The number of "transition times" for which data + # is stored in the file. + timecnt, + + # The number of "local time types" for which data + # is stored in the file (must not be zero). + typecnt, + + # The number of characters of "time zone + # abbreviation strings" stored in the file. + charcnt, + + ) = struct.unpack(">6l", fileobj.read(24)) + + # The above header is followed by tzh_timecnt four-byte + # values of type long, sorted in ascending order. + # These values are written in ``standard'' byte order. + # Each is used as a transition time (as returned by + # time(2)) at which the rules for computing local time + # change. + + if timecnt: + out.trans_list_utc = list(struct.unpack(">%dl" % timecnt, + fileobj.read(timecnt*4))) + else: + out.trans_list_utc = [] + + # Next come tzh_timecnt one-byte values of type unsigned + # char; each one tells which of the different types of + # ``local time'' types described in the file is associated + # with the same-indexed transition time. These values + # serve as indices into an array of ttinfo structures that + # appears next in the file. + + if timecnt: + out.trans_idx = struct.unpack(">%dB" % timecnt, + fileobj.read(timecnt)) + else: + out.trans_idx = [] + + # Each ttinfo structure is written as a four-byte value + # for tt_gmtoff of type long, in a standard byte + # order, followed by a one-byte value for tt_isdst + # and a one-byte value for tt_abbrind. In each + # structure, tt_gmtoff gives the number of + # seconds to be added to UTC, tt_isdst tells whether + # tm_isdst should be set by localtime(3), and + # tt_abbrind serves as an index into the array of + # time zone abbreviation characters that follow the + # ttinfo structure(s) in the file. + + ttinfo = [] + + for i in range(typecnt): + ttinfo.append(struct.unpack(">lbb", fileobj.read(6))) + + abbr = fileobj.read(charcnt).decode() + + # Then there are tzh_leapcnt pairs of four-byte + # values, written in standard byte order; the + # first value of each pair gives the time (as + # returned by time(2)) at which a leap second + # occurs; the second gives the total number of + # leap seconds to be applied after the given time. + # The pairs of values are sorted in ascending order + # by time. + + # Not used, for now (but seek for correct file position) + if leapcnt: + fileobj.seek(leapcnt * 8, os.SEEK_CUR) + + # Then there are tzh_ttisstdcnt standard/wall + # indicators, each stored as a one-byte value; + # they tell whether the transition times associated + # with local time types were specified as standard + # time or wall clock time, and are used when + # a time zone file is used in handling POSIX-style + # time zone environment variables. + + if ttisstdcnt: + isstd = struct.unpack(">%db" % ttisstdcnt, + fileobj.read(ttisstdcnt)) + + # Finally, there are tzh_ttisgmtcnt UTC/local + # indicators, each stored as a one-byte value; + # they tell whether the transition times associated + # with local time types were specified as UTC or + # local time, and are used when a time zone file + # is used in handling POSIX-style time zone envi- + # ronment variables. + + if ttisgmtcnt: + isgmt = struct.unpack(">%db" % ttisgmtcnt, + fileobj.read(ttisgmtcnt)) + + # Build ttinfo list + out.ttinfo_list = [] + for i in range(typecnt): + gmtoff, isdst, abbrind = ttinfo[i] + # Round to full-minutes if that's not the case. Python's + # datetime doesn't accept sub-minute timezones. Check + # http://python.org/sf/1447945 for some information. + gmtoff = 60 * ((gmtoff + 30) // 60) + tti = _ttinfo() + tti.offset = gmtoff + tti.dstoffset = datetime.timedelta(0) + tti.delta = datetime.timedelta(seconds=gmtoff) + tti.isdst = isdst + tti.abbr = abbr[abbrind:abbr.find('\x00', abbrind)] + tti.isstd = (ttisstdcnt > i and isstd[i] != 0) + tti.isgmt = (ttisgmtcnt > i and isgmt[i] != 0) + out.ttinfo_list.append(tti) + + # Replace ttinfo indexes for ttinfo objects. + out.trans_idx = [out.ttinfo_list[idx] for idx in out.trans_idx] + + # Set standard, dst, and before ttinfos. before will be + # used when a given time is before any transitions, + # and will be set to the first non-dst ttinfo, or to + # the first dst, if all of them are dst. + out.ttinfo_std = None + out.ttinfo_dst = None + out.ttinfo_before = None + if out.ttinfo_list: + if not out.trans_list_utc: + out.ttinfo_std = out.ttinfo_first = out.ttinfo_list[0] + else: + for i in range(timecnt-1, -1, -1): + tti = out.trans_idx[i] + if not out.ttinfo_std and not tti.isdst: + out.ttinfo_std = tti + elif not out.ttinfo_dst and tti.isdst: + out.ttinfo_dst = tti + + if out.ttinfo_std and out.ttinfo_dst: + break + else: + if out.ttinfo_dst and not out.ttinfo_std: + out.ttinfo_std = out.ttinfo_dst + + for tti in out.ttinfo_list: + if not tti.isdst: + out.ttinfo_before = tti + break + else: + out.ttinfo_before = out.ttinfo_list[0] + + # Now fix transition times to become relative to wall time. + # + # I'm not sure about this. In my tests, the tz source file + # is setup to wall time, and in the binary file isstd and + # isgmt are off, so it should be in wall time. OTOH, it's + # always in gmt time. Let me know if you have comments + # about this. + laststdoffset = None + out.trans_list = [] + for i, tti in enumerate(out.trans_idx): + if not tti.isdst: + offset = tti.offset + laststdoffset = offset + else: + if laststdoffset is not None: + # Store the DST offset as well and update it in the list + tti.dstoffset = tti.offset - laststdoffset + out.trans_idx[i] = tti + + offset = laststdoffset or 0 + + out.trans_list.append(out.trans_list_utc[i] + offset) + + # In case we missed any DST offsets on the way in for some reason, make + # a second pass over the list, looking for the /next/ DST offset. + laststdoffset = None + for i in reversed(range(len(out.trans_idx))): + tti = out.trans_idx[i] + if tti.isdst: + if not (tti.dstoffset or laststdoffset is None): + tti.dstoffset = tti.offset - laststdoffset + else: + laststdoffset = tti.offset + + if not isinstance(tti.dstoffset, datetime.timedelta): + tti.dstoffset = datetime.timedelta(seconds=tti.dstoffset) + + out.trans_idx[i] = tti + + out.trans_idx = tuple(out.trans_idx) + out.trans_list = tuple(out.trans_list) + out.trans_list_utc = tuple(out.trans_list_utc) + + return out + + def _find_last_transition(self, dt, in_utc=False): + # If there's no list, there are no transitions to find + if not self._trans_list: + return None + + timestamp = _datetime_to_timestamp(dt) + + # Find where the timestamp fits in the transition list - if the + # timestamp is a transition time, it's part of the "after" period. + trans_list = self._trans_list_utc if in_utc else self._trans_list + idx = bisect.bisect_right(trans_list, timestamp) + + # We want to know when the previous transition was, so subtract off 1 + return idx - 1 + + def _get_ttinfo(self, idx): + # For no list or after the last transition, default to _ttinfo_std + if idx is None or (idx + 1) >= len(self._trans_list): + return self._ttinfo_std + + # If there is a list and the time is before it, return _ttinfo_before + if idx < 0: + return self._ttinfo_before + + return self._trans_idx[idx] + + def _find_ttinfo(self, dt): + idx = self._resolve_ambiguous_time(dt) + + return self._get_ttinfo(idx) + + def fromutc(self, dt): + """ + The ``tzfile`` implementation of :py:func:`datetime.tzinfo.fromutc`. + + :param dt: + A :py:class:`datetime.datetime` object. + + :raises TypeError: + Raised if ``dt`` is not a :py:class:`datetime.datetime` object. + + :raises ValueError: + Raised if this is called with a ``dt`` which does not have this + ``tzinfo`` attached. + + :return: + Returns a :py:class:`datetime.datetime` object representing the + wall time in ``self``'s time zone. + """ + # These isinstance checks are in datetime.tzinfo, so we'll preserve + # them, even if we don't care about duck typing. + if not isinstance(dt, datetime.datetime): + raise TypeError("fromutc() requires a datetime argument") + + if dt.tzinfo is not self: + raise ValueError("dt.tzinfo is not self") + + # First treat UTC as wall time and get the transition we're in. + idx = self._find_last_transition(dt, in_utc=True) + tti = self._get_ttinfo(idx) + + dt_out = dt + datetime.timedelta(seconds=tti.offset) + + fold = self.is_ambiguous(dt_out, idx=idx) + + return enfold(dt_out, fold=int(fold)) + + def is_ambiguous(self, dt, idx=None): + """ + Whether or not the "wall time" of a given datetime is ambiguous in this + zone. + + :param dt: + A :py:class:`datetime.datetime`, naive or time zone aware. + + + :return: + Returns ``True`` if ambiguous, ``False`` otherwise. + + .. versionadded:: 2.6.0 + """ + if idx is None: + idx = self._find_last_transition(dt) + + # Calculate the difference in offsets from current to previous + timestamp = _datetime_to_timestamp(dt) + tti = self._get_ttinfo(idx) + + if idx is None or idx <= 0: + return False + + od = self._get_ttinfo(idx - 1).offset - tti.offset + tt = self._trans_list[idx] # Transition time + + return timestamp < tt + od + + def _resolve_ambiguous_time(self, dt): + idx = self._find_last_transition(dt) + + # If we have no transitions, return the index + _fold = self._fold(dt) + if idx is None or idx == 0: + return idx + + # If it's ambiguous and we're in a fold, shift to a different index. + idx_offset = int(not _fold and self.is_ambiguous(dt, idx)) + + return idx - idx_offset + + def utcoffset(self, dt): + if dt is None: + return None + + if not self._ttinfo_std: + return ZERO + + return self._find_ttinfo(dt).delta + + def dst(self, dt): + if dt is None: + return None + + if not self._ttinfo_dst: + return ZERO + + tti = self._find_ttinfo(dt) + + if not tti.isdst: + return ZERO + + # The documentation says that utcoffset()-dst() must + # be constant for every dt. + return tti.dstoffset + + @tzname_in_python2 + def tzname(self, dt): + if not self._ttinfo_std or dt is None: + return None + return self._find_ttinfo(dt).abbr + + def __eq__(self, other): + if not isinstance(other, tzfile): + return NotImplemented + return (self._trans_list == other._trans_list and + self._trans_idx == other._trans_idx and + self._ttinfo_list == other._ttinfo_list) + + __hash__ = None + + def __ne__(self, other): + return not (self == other) + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, repr(self._filename)) + + def __reduce__(self): + return self.__reduce_ex__(None) + + def __reduce_ex__(self, protocol): + return (self.__class__, (None, self._filename), self.__dict__) + + +class tzrange(tzrangebase): + """ + The ``tzrange`` object is a time zone specified by a set of offsets and + abbreviations, equivalent to the way the ``TZ`` variable can be specified + in POSIX-like systems, but using Python delta objects to specify DST + start, end and offsets. + + :param stdabbr: + The abbreviation for standard time (e.g. ``'EST'``). + + :param stdoffset: + An integer or :class:`datetime.timedelta` object or equivalent + specifying the base offset from UTC. + + If unspecified, +00:00 is used. + + :param dstabbr: + The abbreviation for DST / "Summer" time (e.g. ``'EDT'``). + + If specified, with no other DST information, DST is assumed to occur + and the default behavior or ``dstoffset``, ``start`` and ``end`` is + used. If unspecified and no other DST information is specified, it + is assumed that this zone has no DST. + + If this is unspecified and other DST information is *is* specified, + DST occurs in the zone but the time zone abbreviation is left + unchanged. + + :param dstoffset: + A an integer or :class:`datetime.timedelta` object or equivalent + specifying the UTC offset during DST. If unspecified and any other DST + information is specified, it is assumed to be the STD offset +1 hour. + + :param start: + A :class:`relativedelta.relativedelta` object or equivalent specifying + the time and time of year that daylight savings time starts. To + specify, for example, that DST starts at 2AM on the 2nd Sunday in + March, pass: + + ``relativedelta(hours=2, month=3, day=1, weekday=SU(+2))`` + + If unspecified and any other DST information is specified, the default + value is 2 AM on the first Sunday in April. + + :param end: + A :class:`relativedelta.relativedelta` object or equivalent + representing the time and time of year that daylight savings time + ends, with the same specification method as in ``start``. One note is + that this should point to the first time in the *standard* zone, so if + a transition occurs at 2AM in the DST zone and the clocks are set back + 1 hour to 1AM, set the ``hours`` parameter to +1. + + + **Examples:** + + .. testsetup:: tzrange + + from dateutil.tz import tzrange, tzstr + + .. doctest:: tzrange + + >>> tzstr('EST5EDT') == tzrange("EST", -18000, "EDT") + True + + >>> from dateutil.relativedelta import * + >>> range1 = tzrange("EST", -18000, "EDT") + >>> range2 = tzrange("EST", -18000, "EDT", -14400, + ... relativedelta(hours=+2, month=4, day=1, + ... weekday=SU(+1)), + ... relativedelta(hours=+1, month=10, day=31, + ... weekday=SU(-1))) + >>> tzstr('EST5EDT') == range1 == range2 + True + + """ + def __init__(self, stdabbr, stdoffset=None, + dstabbr=None, dstoffset=None, + start=None, end=None): + + global relativedelta + from dateutil import relativedelta + + self._std_abbr = stdabbr + self._dst_abbr = dstabbr + + try: + stdoffset = stdoffset.total_seconds() + except (TypeError, AttributeError): + pass + + try: + dstoffset = dstoffset.total_seconds() + except (TypeError, AttributeError): + pass + + if stdoffset is not None: + self._std_offset = datetime.timedelta(seconds=stdoffset) + else: + self._std_offset = ZERO + + if dstoffset is not None: + self._dst_offset = datetime.timedelta(seconds=dstoffset) + elif dstabbr and stdoffset is not None: + self._dst_offset = self._std_offset + datetime.timedelta(hours=+1) + else: + self._dst_offset = ZERO + + if dstabbr and start is None: + self._start_delta = relativedelta.relativedelta( + hours=+2, month=4, day=1, weekday=relativedelta.SU(+1)) + else: + self._start_delta = start + + if dstabbr and end is None: + self._end_delta = relativedelta.relativedelta( + hours=+1, month=10, day=31, weekday=relativedelta.SU(-1)) + else: + self._end_delta = end + + self._dst_base_offset_ = self._dst_offset - self._std_offset + self.hasdst = bool(self._start_delta) + + def transitions(self, year): + """ + For a given year, get the DST on and off transition times, expressed + always on the standard time side. For zones with no transitions, this + function returns ``None``. + + :param year: + The year whose transitions you would like to query. + + :return: + Returns a :class:`tuple` of :class:`datetime.datetime` objects, + ``(dston, dstoff)`` for zones with an annual DST transition, or + ``None`` for fixed offset zones. + """ + if not self.hasdst: + return None + + base_year = datetime.datetime(year, 1, 1) + + start = base_year + self._start_delta + end = base_year + self._end_delta + + return (start, end) + + def __eq__(self, other): + if not isinstance(other, tzrange): + return NotImplemented + + return (self._std_abbr == other._std_abbr and + self._dst_abbr == other._dst_abbr and + self._std_offset == other._std_offset and + self._dst_offset == other._dst_offset and + self._start_delta == other._start_delta and + self._end_delta == other._end_delta) + + @property + def _dst_base_offset(self): + return self._dst_base_offset_ + + +@six.add_metaclass(_TzStrFactory) +class tzstr(tzrange): + """ + ``tzstr`` objects are time zone objects specified by a time-zone string as + it would be passed to a ``TZ`` variable on POSIX-style systems (see + the `GNU C Library: TZ Variable`_ for more details). + + There is one notable exception, which is that POSIX-style time zones use an + inverted offset format, so normally ``GMT+3`` would be parsed as an offset + 3 hours *behind* GMT. The ``tzstr`` time zone object will parse this as an + offset 3 hours *ahead* of GMT. If you would like to maintain the POSIX + behavior, pass a ``True`` value to ``posix_offset``. + + The :class:`tzrange` object provides the same functionality, but is + specified using :class:`relativedelta.relativedelta` objects. rather than + strings. + + :param s: + A time zone string in ``TZ`` variable format. This can be a + :class:`bytes` (2.x: :class:`str`), :class:`str` (2.x: + :class:`unicode`) or a stream emitting unicode characters + (e.g. :class:`StringIO`). + + :param posix_offset: + Optional. If set to ``True``, interpret strings such as ``GMT+3`` or + ``UTC+3`` as being 3 hours *behind* UTC rather than ahead, per the + POSIX standard. + + .. caution:: + + Prior to version 2.7.0, this function also supported time zones + in the format: + + * ``EST5EDT,4,0,6,7200,10,0,26,7200,3600`` + * ``EST5EDT,4,1,0,7200,10,-1,0,7200,3600`` + + This format is non-standard and has been deprecated; this function + will raise a :class:`DeprecatedTZFormatWarning` until + support is removed in a future version. + + .. _`GNU C Library: TZ Variable`: + https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html + """ + def __init__(self, s, posix_offset=False): + global parser + from dateutil.parser import _parser as parser + + self._s = s + + res = parser._parsetz(s) + if res is None or res.any_unused_tokens: + raise ValueError("unknown string format") + + # Here we break the compatibility with the TZ variable handling. + # GMT-3 actually *means* the timezone -3. + if res.stdabbr in ("GMT", "UTC") and not posix_offset: + res.stdoffset *= -1 + + # We must initialize it first, since _delta() needs + # _std_offset and _dst_offset set. Use False in start/end + # to avoid building it two times. + tzrange.__init__(self, res.stdabbr, res.stdoffset, + res.dstabbr, res.dstoffset, + start=False, end=False) + + if not res.dstabbr: + self._start_delta = None + self._end_delta = None + else: + self._start_delta = self._delta(res.start) + if self._start_delta: + self._end_delta = self._delta(res.end, isend=1) + + self.hasdst = bool(self._start_delta) + + def _delta(self, x, isend=0): + from dateutil import relativedelta + kwargs = {} + if x.month is not None: + kwargs["month"] = x.month + if x.weekday is not None: + kwargs["weekday"] = relativedelta.weekday(x.weekday, x.week) + if x.week > 0: + kwargs["day"] = 1 + else: + kwargs["day"] = 31 + elif x.day: + kwargs["day"] = x.day + elif x.yday is not None: + kwargs["yearday"] = x.yday + elif x.jyday is not None: + kwargs["nlyearday"] = x.jyday + if not kwargs: + # Default is to start on first sunday of april, and end + # on last sunday of october. + if not isend: + kwargs["month"] = 4 + kwargs["day"] = 1 + kwargs["weekday"] = relativedelta.SU(+1) + else: + kwargs["month"] = 10 + kwargs["day"] = 31 + kwargs["weekday"] = relativedelta.SU(-1) + if x.time is not None: + kwargs["seconds"] = x.time + else: + # Default is 2AM. + kwargs["seconds"] = 7200 + if isend: + # Convert to standard time, to follow the documented way + # of working with the extra hour. See the documentation + # of the tzinfo class. + delta = self._dst_offset - self._std_offset + kwargs["seconds"] -= delta.seconds + delta.days * 86400 + return relativedelta.relativedelta(**kwargs) + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, repr(self._s)) + + +class _tzicalvtzcomp(object): + def __init__(self, tzoffsetfrom, tzoffsetto, isdst, + tzname=None, rrule=None): + self.tzoffsetfrom = datetime.timedelta(seconds=tzoffsetfrom) + self.tzoffsetto = datetime.timedelta(seconds=tzoffsetto) + self.tzoffsetdiff = self.tzoffsetto - self.tzoffsetfrom + self.isdst = isdst + self.tzname = tzname + self.rrule = rrule + + +class _tzicalvtz(_tzinfo): + def __init__(self, tzid, comps=[]): + super(_tzicalvtz, self).__init__() + + self._tzid = tzid + self._comps = comps + self._cachedate = [] + self._cachecomp = [] + self._cache_lock = _thread.allocate_lock() + + def _find_comp(self, dt): + if len(self._comps) == 1: + return self._comps[0] + + dt = dt.replace(tzinfo=None) + + try: + with self._cache_lock: + return self._cachecomp[self._cachedate.index( + (dt, self._fold(dt)))] + except ValueError: + pass + + lastcompdt = None + lastcomp = None + + for comp in self._comps: + compdt = self._find_compdt(comp, dt) + + if compdt and (not lastcompdt or lastcompdt < compdt): + lastcompdt = compdt + lastcomp = comp + + if not lastcomp: + # RFC says nothing about what to do when a given + # time is before the first onset date. We'll look for the + # first standard component, or the first component, if + # none is found. + for comp in self._comps: + if not comp.isdst: + lastcomp = comp + break + else: + lastcomp = comp[0] + + with self._cache_lock: + self._cachedate.insert(0, (dt, self._fold(dt))) + self._cachecomp.insert(0, lastcomp) + + if len(self._cachedate) > 10: + self._cachedate.pop() + self._cachecomp.pop() + + return lastcomp + + def _find_compdt(self, comp, dt): + if comp.tzoffsetdiff < ZERO and self._fold(dt): + dt -= comp.tzoffsetdiff + + compdt = comp.rrule.before(dt, inc=True) + + return compdt + + def utcoffset(self, dt): + if dt is None: + return None + + return self._find_comp(dt).tzoffsetto + + def dst(self, dt): + comp = self._find_comp(dt) + if comp.isdst: + return comp.tzoffsetdiff + else: + return ZERO + + @tzname_in_python2 + def tzname(self, dt): + return self._find_comp(dt).tzname + + def __repr__(self): + return "" % repr(self._tzid) + + __reduce__ = object.__reduce__ + + +class tzical(object): + """ + This object is designed to parse an iCalendar-style ``VTIMEZONE`` structure + as set out in `RFC 5545`_ Section 4.6.5 into one or more `tzinfo` objects. + + :param `fileobj`: + A file or stream in iCalendar format, which should be UTF-8 encoded + with CRLF endings. + + .. _`RFC 5545`: https://tools.ietf.org/html/rfc5545 + """ + def __init__(self, fileobj): + global rrule + from dateutil import rrule + + if isinstance(fileobj, string_types): + self._s = fileobj + # ical should be encoded in UTF-8 with CRLF + fileobj = open(fileobj, 'r') + else: + self._s = getattr(fileobj, 'name', repr(fileobj)) + fileobj = _ContextWrapper(fileobj) + + self._vtz = {} + + with fileobj as fobj: + self._parse_rfc(fobj.read()) + + def keys(self): + """ + Retrieves the available time zones as a list. + """ + return list(self._vtz.keys()) + + def get(self, tzid=None): + """ + Retrieve a :py:class:`datetime.tzinfo` object by its ``tzid``. + + :param tzid: + If there is exactly one time zone available, omitting ``tzid`` + or passing :py:const:`None` value returns it. Otherwise a valid + key (which can be retrieved from :func:`keys`) is required. + + :raises ValueError: + Raised if ``tzid`` is not specified but there are either more + or fewer than 1 zone defined. + + :returns: + Returns either a :py:class:`datetime.tzinfo` object representing + the relevant time zone or :py:const:`None` if the ``tzid`` was + not found. + """ + if tzid is None: + if len(self._vtz) == 0: + raise ValueError("no timezones defined") + elif len(self._vtz) > 1: + raise ValueError("more than one timezone available") + tzid = next(iter(self._vtz)) + + return self._vtz.get(tzid) + + def _parse_offset(self, s): + s = s.strip() + if not s: + raise ValueError("empty offset") + if s[0] in ('+', '-'): + signal = (-1, +1)[s[0] == '+'] + s = s[1:] + else: + signal = +1 + if len(s) == 4: + return (int(s[:2]) * 3600 + int(s[2:]) * 60) * signal + elif len(s) == 6: + return (int(s[:2]) * 3600 + int(s[2:4]) * 60 + int(s[4:])) * signal + else: + raise ValueError("invalid offset: " + s) + + def _parse_rfc(self, s): + lines = s.splitlines() + if not lines: + raise ValueError("empty string") + + # Unfold + i = 0 + while i < len(lines): + line = lines[i].rstrip() + if not line: + del lines[i] + elif i > 0 and line[0] == " ": + lines[i-1] += line[1:] + del lines[i] + else: + i += 1 + + tzid = None + comps = [] + invtz = False + comptype = None + for line in lines: + if not line: + continue + name, value = line.split(':', 1) + parms = name.split(';') + if not parms: + raise ValueError("empty property name") + name = parms[0].upper() + parms = parms[1:] + if invtz: + if name == "BEGIN": + if value in ("STANDARD", "DAYLIGHT"): + # Process component + pass + else: + raise ValueError("unknown component: "+value) + comptype = value + founddtstart = False + tzoffsetfrom = None + tzoffsetto = None + rrulelines = [] + tzname = None + elif name == "END": + if value == "VTIMEZONE": + if comptype: + raise ValueError("component not closed: "+comptype) + if not tzid: + raise ValueError("mandatory TZID not found") + if not comps: + raise ValueError( + "at least one component is needed") + # Process vtimezone + self._vtz[tzid] = _tzicalvtz(tzid, comps) + invtz = False + elif value == comptype: + if not founddtstart: + raise ValueError("mandatory DTSTART not found") + if tzoffsetfrom is None: + raise ValueError( + "mandatory TZOFFSETFROM not found") + if tzoffsetto is None: + raise ValueError( + "mandatory TZOFFSETFROM not found") + # Process component + rr = None + if rrulelines: + rr = rrule.rrulestr("\n".join(rrulelines), + compatible=True, + ignoretz=True, + cache=True) + comp = _tzicalvtzcomp(tzoffsetfrom, tzoffsetto, + (comptype == "DAYLIGHT"), + tzname, rr) + comps.append(comp) + comptype = None + else: + raise ValueError("invalid component end: "+value) + elif comptype: + if name == "DTSTART": + # DTSTART in VTIMEZONE takes a subset of valid RRULE + # values under RFC 5545. + for parm in parms: + if parm != 'VALUE=DATE-TIME': + msg = ('Unsupported DTSTART param in ' + + 'VTIMEZONE: ' + parm) + raise ValueError(msg) + rrulelines.append(line) + founddtstart = True + elif name in ("RRULE", "RDATE", "EXRULE", "EXDATE"): + rrulelines.append(line) + elif name == "TZOFFSETFROM": + if parms: + raise ValueError( + "unsupported %s parm: %s " % (name, parms[0])) + tzoffsetfrom = self._parse_offset(value) + elif name == "TZOFFSETTO": + if parms: + raise ValueError( + "unsupported TZOFFSETTO parm: "+parms[0]) + tzoffsetto = self._parse_offset(value) + elif name == "TZNAME": + if parms: + raise ValueError( + "unsupported TZNAME parm: "+parms[0]) + tzname = value + elif name == "COMMENT": + pass + else: + raise ValueError("unsupported property: "+name) + else: + if name == "TZID": + if parms: + raise ValueError( + "unsupported TZID parm: "+parms[0]) + tzid = value + elif name in ("TZURL", "LAST-MODIFIED", "COMMENT"): + pass + else: + raise ValueError("unsupported property: "+name) + elif name == "BEGIN" and value == "VTIMEZONE": + tzid = None + comps = [] + invtz = True + + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, repr(self._s)) + + +if sys.platform != "win32": + TZFILES = ["/etc/localtime", "localtime"] + TZPATHS = ["/usr/share/zoneinfo", + "/usr/lib/zoneinfo", + "/usr/share/lib/zoneinfo", + "/etc/zoneinfo"] +else: + TZFILES = [] + TZPATHS = [] + + +def __get_gettz(): + tzlocal_classes = (tzlocal,) + if tzwinlocal is not None: + tzlocal_classes += (tzwinlocal,) + + class GettzFunc(object): + """ + Retrieve a time zone object from a string representation + + This function is intended to retrieve the :py:class:`tzinfo` subclass + that best represents the time zone that would be used if a POSIX + `TZ variable`_ were set to the same value. + + If no argument or an empty string is passed to ``gettz``, local time + is returned: + + .. code-block:: python3 + + >>> gettz() + tzfile('/etc/localtime') + + This function is also the preferred way to map IANA tz database keys + to :class:`tzfile` objects: + + .. code-block:: python3 + + >>> gettz('Pacific/Kiritimati') + tzfile('/usr/share/zoneinfo/Pacific/Kiritimati') + + On Windows, the standard is extended to include the Windows-specific + zone names provided by the operating system: + + .. code-block:: python3 + + >>> gettz('Egypt Standard Time') + tzwin('Egypt Standard Time') + + Passing a GNU ``TZ`` style string time zone specification returns a + :class:`tzstr` object: + + .. code-block:: python3 + + >>> gettz('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3') + tzstr('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3') + + :param name: + A time zone name (IANA, or, on Windows, Windows keys), location of + a ``tzfile(5)`` zoneinfo file or ``TZ`` variable style time zone + specifier. An empty string, no argument or ``None`` is interpreted + as local time. + + :return: + Returns an instance of one of ``dateutil``'s :py:class:`tzinfo` + subclasses. + + .. versionchanged:: 2.7.0 + + After version 2.7.0, any two calls to ``gettz`` using the same + input strings will return the same object: + + .. code-block:: python3 + + >>> tz.gettz('America/Chicago') is tz.gettz('America/Chicago') + True + + In addition to improving performance, this ensures that + `"same zone" semantics`_ are used for datetimes in the same zone. + + + .. _`TZ variable`: + https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html + + .. _`"same zone" semantics`: + https://blog.ganssle.io/articles/2018/02/aware-datetime-arithmetic.html + """ + def __init__(self): + + self.__instances = {} + self._cache_lock = _thread.allocate_lock() + + def __call__(self, name=None): + with self._cache_lock: + rv = self.__instances.get(name, None) + + if rv is None: + rv = self.nocache(name=name) + if not (name is None or isinstance(rv, tzlocal_classes)): + # tzlocal is slightly more complicated than the other + # time zone providers because it depends on environment + # at construction time, so don't cache that. + self.__instances[name] = rv + + return rv + + def cache_clear(self): + with self._cache_lock: + self.__instances = {} + + @staticmethod + def nocache(name=None): + """A non-cached version of gettz""" + tz = None + if not name: + try: + name = os.environ["TZ"] + except KeyError: + pass + if name is None or name == ":": + for filepath in TZFILES: + if not os.path.isabs(filepath): + filename = filepath + for path in TZPATHS: + filepath = os.path.join(path, filename) + if os.path.isfile(filepath): + break + else: + continue + if os.path.isfile(filepath): + try: + tz = tzfile(filepath) + break + except (IOError, OSError, ValueError): + pass + else: + tz = tzlocal() + else: + if name.startswith(":"): + name = name[1:] + if os.path.isabs(name): + if os.path.isfile(name): + tz = tzfile(name) + else: + tz = None + else: + for path in TZPATHS: + filepath = os.path.join(path, name) + if not os.path.isfile(filepath): + filepath = filepath.replace(' ', '_') + if not os.path.isfile(filepath): + continue + try: + tz = tzfile(filepath) + break + except (IOError, OSError, ValueError): + pass + else: + tz = None + if tzwin is not None: + try: + tz = tzwin(name) + except WindowsError: + tz = None + + if not tz: + from dateutil.zoneinfo import get_zonefile_instance + tz = get_zonefile_instance().get(name) + + if not tz: + for c in name: + # name is not a tzstr unless it has at least + # one offset. For short values of "name", an + # explicit for loop seems to be the fastest way + # To determine if a string contains a digit + if c in "0123456789": + try: + tz = tzstr(name) + except ValueError: + pass + break + else: + if name in ("GMT", "UTC"): + tz = tzutc() + elif name in time.tzname: + tz = tzlocal() + return tz + + return GettzFunc() + + +gettz = __get_gettz() +del __get_gettz + + +def datetime_exists(dt, tz=None): + """ + Given a datetime and a time zone, determine whether or not a given datetime + would fall in a gap. + + :param dt: + A :class:`datetime.datetime` (whose time zone will be ignored if ``tz`` + is provided.) + + :param tz: + A :class:`datetime.tzinfo` with support for the ``fold`` attribute. If + ``None`` or not provided, the datetime's own time zone will be used. + + :return: + Returns a boolean value whether or not the "wall time" exists in + ``tz``. + + .. versionadded:: 2.7.0 + """ + if tz is None: + if dt.tzinfo is None: + raise ValueError('Datetime is naive and no time zone provided.') + tz = dt.tzinfo + + dt = dt.replace(tzinfo=None) + + # This is essentially a test of whether or not the datetime can survive + # a round trip to UTC. + dt_rt = dt.replace(tzinfo=tz).astimezone(tzutc()).astimezone(tz) + dt_rt = dt_rt.replace(tzinfo=None) + + return dt == dt_rt + + +def datetime_ambiguous(dt, tz=None): + """ + Given a datetime and a time zone, determine whether or not a given datetime + is ambiguous (i.e if there are two times differentiated only by their DST + status). + + :param dt: + A :class:`datetime.datetime` (whose time zone will be ignored if ``tz`` + is provided.) + + :param tz: + A :class:`datetime.tzinfo` with support for the ``fold`` attribute. If + ``None`` or not provided, the datetime's own time zone will be used. + + :return: + Returns a boolean value whether or not the "wall time" is ambiguous in + ``tz``. + + .. versionadded:: 2.6.0 + """ + if tz is None: + if dt.tzinfo is None: + raise ValueError('Datetime is naive and no time zone provided.') + + tz = dt.tzinfo + + # If a time zone defines its own "is_ambiguous" function, we'll use that. + is_ambiguous_fn = getattr(tz, 'is_ambiguous', None) + if is_ambiguous_fn is not None: + try: + return tz.is_ambiguous(dt) + except Exception: + pass + + # If it doesn't come out and tell us it's ambiguous, we'll just check if + # the fold attribute has any effect on this particular date and time. + dt = dt.replace(tzinfo=tz) + wall_0 = enfold(dt, fold=0) + wall_1 = enfold(dt, fold=1) + + same_offset = wall_0.utcoffset() == wall_1.utcoffset() + same_dst = wall_0.dst() == wall_1.dst() + + return not (same_offset and same_dst) + + +def resolve_imaginary(dt): + """ + Given a datetime that may be imaginary, return an existing datetime. + + This function assumes that an imaginary datetime represents what the + wall time would be in a zone had the offset transition not occurred, so + it will always fall forward by the transition's change in offset. + + .. doctest:: + + >>> from dateutil import tz + >>> from datetime import datetime + >>> NYC = tz.gettz('America/New_York') + >>> print(tz.resolve_imaginary(datetime(2017, 3, 12, 2, 30, tzinfo=NYC))) + 2017-03-12 03:30:00-04:00 + + >>> KIR = tz.gettz('Pacific/Kiritimati') + >>> print(tz.resolve_imaginary(datetime(1995, 1, 1, 12, 30, tzinfo=KIR))) + 1995-01-02 12:30:00+14:00 + + As a note, :func:`datetime.astimezone` is guaranteed to produce a valid, + existing datetime, so a round-trip to and from UTC is sufficient to get + an extant datetime, however, this generally "falls back" to an earlier time + rather than falling forward to the STD side (though no guarantees are made + about this behavior). + + :param dt: + A :class:`datetime.datetime` which may or may not exist. + + :return: + Returns an existing :class:`datetime.datetime`. If ``dt`` was not + imaginary, the datetime returned is guaranteed to be the same object + passed to the function. + + .. versionadded:: 2.7.0 + """ + if dt.tzinfo is not None and not datetime_exists(dt): + + curr_offset = (dt + datetime.timedelta(hours=24)).utcoffset() + old_offset = (dt - datetime.timedelta(hours=24)).utcoffset() + + dt += curr_offset - old_offset + + return dt + + +def _datetime_to_timestamp(dt): + """ + Convert a :class:`datetime.datetime` object to an epoch timestamp in + seconds since January 1, 1970, ignoring the time zone. + """ + return (dt.replace(tzinfo=None) - EPOCH).total_seconds() + + +class _ContextWrapper(object): + """ + Class for wrapping contexts so that they are passed through in a + with statement. + """ + def __init__(self, context): + self.context = context + + def __enter__(self): + return self.context + + def __exit__(*args, **kwargs): + pass + +# vim:ts=4:sw=4:et diff --git a/libs/dateutil/tz/win.py b/libs/dateutil/tz/win.py new file mode 100644 index 00000000..def4353a --- /dev/null +++ b/libs/dateutil/tz/win.py @@ -0,0 +1,331 @@ +# This code was originally contributed by Jeffrey Harris. +import datetime +import struct + +from six.moves import winreg +from six import text_type + +try: + import ctypes + from ctypes import wintypes +except ValueError: + # ValueError is raised on non-Windows systems for some horrible reason. + raise ImportError("Running tzwin on non-Windows system") + +from ._common import tzrangebase + +__all__ = ["tzwin", "tzwinlocal", "tzres"] + +ONEWEEK = datetime.timedelta(7) + +TZKEYNAMENT = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones" +TZKEYNAME9X = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Time Zones" +TZLOCALKEYNAME = r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation" + + +def _settzkeyname(): + handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) + try: + winreg.OpenKey(handle, TZKEYNAMENT).Close() + TZKEYNAME = TZKEYNAMENT + except WindowsError: + TZKEYNAME = TZKEYNAME9X + handle.Close() + return TZKEYNAME + + +TZKEYNAME = _settzkeyname() + + +class tzres(object): + """ + Class for accessing `tzres.dll`, which contains timezone name related + resources. + + .. versionadded:: 2.5.0 + """ + p_wchar = ctypes.POINTER(wintypes.WCHAR) # Pointer to a wide char + + def __init__(self, tzres_loc='tzres.dll'): + # Load the user32 DLL so we can load strings from tzres + user32 = ctypes.WinDLL('user32') + + # Specify the LoadStringW function + user32.LoadStringW.argtypes = (wintypes.HINSTANCE, + wintypes.UINT, + wintypes.LPWSTR, + ctypes.c_int) + + self.LoadStringW = user32.LoadStringW + self._tzres = ctypes.WinDLL(tzres_loc) + self.tzres_loc = tzres_loc + + def load_name(self, offset): + """ + Load a timezone name from a DLL offset (integer). + + >>> from dateutil.tzwin import tzres + >>> tzr = tzres() + >>> print(tzr.load_name(112)) + 'Eastern Standard Time' + + :param offset: + A positive integer value referring to a string from the tzres dll. + + ..note: + Offsets found in the registry are generally of the form + `@tzres.dll,-114`. The offset in this case if 114, not -114. + + """ + resource = self.p_wchar() + lpBuffer = ctypes.cast(ctypes.byref(resource), wintypes.LPWSTR) + nchar = self.LoadStringW(self._tzres._handle, offset, lpBuffer, 0) + return resource[:nchar] + + def name_from_string(self, tzname_str): + """ + Parse strings as returned from the Windows registry into the time zone + name as defined in the registry. + + >>> from dateutil.tzwin import tzres + >>> tzr = tzres() + >>> print(tzr.name_from_string('@tzres.dll,-251')) + 'Dateline Daylight Time' + >>> print(tzr.name_from_string('Eastern Standard Time')) + 'Eastern Standard Time' + + :param tzname_str: + A timezone name string as returned from a Windows registry key. + + :return: + Returns the localized timezone string from tzres.dll if the string + is of the form `@tzres.dll,-offset`, else returns the input string. + """ + if not tzname_str.startswith('@'): + return tzname_str + + name_splt = tzname_str.split(',-') + try: + offset = int(name_splt[1]) + except: + raise ValueError("Malformed timezone string.") + + return self.load_name(offset) + + +class tzwinbase(tzrangebase): + """tzinfo class based on win32's timezones available in the registry.""" + def __init__(self): + raise NotImplementedError('tzwinbase is an abstract base class') + + def __eq__(self, other): + # Compare on all relevant dimensions, including name. + if not isinstance(other, tzwinbase): + return NotImplemented + + return (self._std_offset == other._std_offset and + self._dst_offset == other._dst_offset and + self._stddayofweek == other._stddayofweek and + self._dstdayofweek == other._dstdayofweek and + self._stdweeknumber == other._stdweeknumber and + self._dstweeknumber == other._dstweeknumber and + self._stdhour == other._stdhour and + self._dsthour == other._dsthour and + self._stdminute == other._stdminute and + self._dstminute == other._dstminute and + self._std_abbr == other._std_abbr and + self._dst_abbr == other._dst_abbr) + + @staticmethod + def list(): + """Return a list of all time zones known to the system.""" + with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle: + with winreg.OpenKey(handle, TZKEYNAME) as tzkey: + result = [winreg.EnumKey(tzkey, i) + for i in range(winreg.QueryInfoKey(tzkey)[0])] + return result + + def display(self): + return self._display + + def transitions(self, year): + """ + For a given year, get the DST on and off transition times, expressed + always on the standard time side. For zones with no transitions, this + function returns ``None``. + + :param year: + The year whose transitions you would like to query. + + :return: + Returns a :class:`tuple` of :class:`datetime.datetime` objects, + ``(dston, dstoff)`` for zones with an annual DST transition, or + ``None`` for fixed offset zones. + """ + + if not self.hasdst: + return None + + dston = picknthweekday(year, self._dstmonth, self._dstdayofweek, + self._dsthour, self._dstminute, + self._dstweeknumber) + + dstoff = picknthweekday(year, self._stdmonth, self._stddayofweek, + self._stdhour, self._stdminute, + self._stdweeknumber) + + # Ambiguous dates default to the STD side + dstoff -= self._dst_base_offset + + return dston, dstoff + + def _get_hasdst(self): + return self._dstmonth != 0 + + @property + def _dst_base_offset(self): + return self._dst_base_offset_ + + +class tzwin(tzwinbase): + + def __init__(self, name): + self._name = name + + with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle: + tzkeyname = text_type("{kn}\\{name}").format(kn=TZKEYNAME, name=name) + with winreg.OpenKey(handle, tzkeyname) as tzkey: + keydict = valuestodict(tzkey) + + self._std_abbr = keydict["Std"] + self._dst_abbr = keydict["Dlt"] + + self._display = keydict["Display"] + + # See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm + tup = struct.unpack("=3l16h", keydict["TZI"]) + stdoffset = -tup[0]-tup[1] # Bias + StandardBias * -1 + dstoffset = stdoffset-tup[2] # + DaylightBias * -1 + self._std_offset = datetime.timedelta(minutes=stdoffset) + self._dst_offset = datetime.timedelta(minutes=dstoffset) + + # for the meaning see the win32 TIME_ZONE_INFORMATION structure docs + # http://msdn.microsoft.com/en-us/library/windows/desktop/ms725481(v=vs.85).aspx + (self._stdmonth, + self._stddayofweek, # Sunday = 0 + self._stdweeknumber, # Last = 5 + self._stdhour, + self._stdminute) = tup[4:9] + + (self._dstmonth, + self._dstdayofweek, # Sunday = 0 + self._dstweeknumber, # Last = 5 + self._dsthour, + self._dstminute) = tup[12:17] + + self._dst_base_offset_ = self._dst_offset - self._std_offset + self.hasdst = self._get_hasdst() + + def __repr__(self): + return "tzwin(%s)" % repr(self._name) + + def __reduce__(self): + return (self.__class__, (self._name,)) + + +class tzwinlocal(tzwinbase): + def __init__(self): + with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle: + with winreg.OpenKey(handle, TZLOCALKEYNAME) as tzlocalkey: + keydict = valuestodict(tzlocalkey) + + self._std_abbr = keydict["StandardName"] + self._dst_abbr = keydict["DaylightName"] + + try: + tzkeyname = text_type('{kn}\\{sn}').format(kn=TZKEYNAME, + sn=self._std_abbr) + with winreg.OpenKey(handle, tzkeyname) as tzkey: + _keydict = valuestodict(tzkey) + self._display = _keydict["Display"] + except OSError: + self._display = None + + stdoffset = -keydict["Bias"]-keydict["StandardBias"] + dstoffset = stdoffset-keydict["DaylightBias"] + + self._std_offset = datetime.timedelta(minutes=stdoffset) + self._dst_offset = datetime.timedelta(minutes=dstoffset) + + # For reasons unclear, in this particular key, the day of week has been + # moved to the END of the SYSTEMTIME structure. + tup = struct.unpack("=8h", keydict["StandardStart"]) + + (self._stdmonth, + self._stdweeknumber, # Last = 5 + self._stdhour, + self._stdminute) = tup[1:5] + + self._stddayofweek = tup[7] + + tup = struct.unpack("=8h", keydict["DaylightStart"]) + + (self._dstmonth, + self._dstweeknumber, # Last = 5 + self._dsthour, + self._dstminute) = tup[1:5] + + self._dstdayofweek = tup[7] + + self._dst_base_offset_ = self._dst_offset - self._std_offset + self.hasdst = self._get_hasdst() + + def __repr__(self): + return "tzwinlocal()" + + def __str__(self): + # str will return the standard name, not the daylight name. + return "tzwinlocal(%s)" % repr(self._std_abbr) + + def __reduce__(self): + return (self.__class__, ()) + + +def picknthweekday(year, month, dayofweek, hour, minute, whichweek): + """ dayofweek == 0 means Sunday, whichweek 5 means last instance """ + first = datetime.datetime(year, month, 1, hour, minute) + + # This will work if dayofweek is ISO weekday (1-7) or Microsoft-style (0-6), + # Because 7 % 7 = 0 + weekdayone = first.replace(day=((dayofweek - first.isoweekday()) % 7) + 1) + wd = weekdayone + ((whichweek - 1) * ONEWEEK) + if (wd.month != month): + wd -= ONEWEEK + + return wd + + +def valuestodict(key): + """Convert a registry key's values to a dictionary.""" + dout = {} + size = winreg.QueryInfoKey(key)[1] + tz_res = None + + for i in range(size): + key_name, value, dtype = winreg.EnumValue(key, i) + if dtype == winreg.REG_DWORD or dtype == winreg.REG_DWORD_LITTLE_ENDIAN: + # If it's a DWORD (32-bit integer), it's stored as unsigned - convert + # that to a proper signed integer + if value & (1 << 31): + value = value - (1 << 32) + elif dtype == winreg.REG_SZ: + # If it's a reference to the tzres DLL, load the actual string + if value.startswith('@tzres'): + tz_res = tz_res or tzres() + value = tz_res.name_from_string(value) + + value = value.rstrip('\x00') # Remove trailing nulls + + dout[key_name] = value + + return dout diff --git a/libs/dateutil/tzwin.py b/libs/dateutil/tzwin.py index 073e0ff6..cebc673e 100644 --- a/libs/dateutil/tzwin.py +++ b/libs/dateutil/tzwin.py @@ -1,180 +1,2 @@ -# This code was originally contributed by Jeffrey Harris. -import datetime -import struct -import _winreg - -__author__ = "Jeffrey Harris & Gustavo Niemeyer " - -__all__ = ["tzwin", "tzwinlocal"] - -ONEWEEK = datetime.timedelta(7) - -TZKEYNAMENT = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones" -TZKEYNAME9X = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Time Zones" -TZLOCALKEYNAME = r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation" - -def _settzkeyname(): - global TZKEYNAME - handle = _winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) - try: - _winreg.OpenKey(handle, TZKEYNAMENT).Close() - TZKEYNAME = TZKEYNAMENT - except WindowsError: - TZKEYNAME = TZKEYNAME9X - handle.Close() - -_settzkeyname() - -class tzwinbase(datetime.tzinfo): - """tzinfo class based on win32's timezones available in the registry.""" - - def utcoffset(self, dt): - if self._isdst(dt): - return datetime.timedelta(minutes=self._dstoffset) - else: - return datetime.timedelta(minutes=self._stdoffset) - - def dst(self, dt): - if self._isdst(dt): - minutes = self._dstoffset - self._stdoffset - return datetime.timedelta(minutes=minutes) - else: - return datetime.timedelta(0) - - def tzname(self, dt): - if self._isdst(dt): - return self._dstname - else: - return self._stdname - - def list(): - """Return a list of all time zones known to the system.""" - handle = _winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) - tzkey = _winreg.OpenKey(handle, TZKEYNAME) - result = [_winreg.EnumKey(tzkey, i) - for i in range(_winreg.QueryInfoKey(tzkey)[0])] - tzkey.Close() - handle.Close() - return result - list = staticmethod(list) - - def display(self): - return self._display - - def _isdst(self, dt): - dston = picknthweekday(dt.year, self._dstmonth, self._dstdayofweek, - self._dsthour, self._dstminute, - self._dstweeknumber) - dstoff = picknthweekday(dt.year, self._stdmonth, self._stddayofweek, - self._stdhour, self._stdminute, - self._stdweeknumber) - if dston < dstoff: - return dston <= dt.replace(tzinfo=None) < dstoff - else: - return not dstoff <= dt.replace(tzinfo=None) < dston - - -class tzwin(tzwinbase): - - def __init__(self, name): - self._name = name - - handle = _winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) - tzkey = _winreg.OpenKey(handle, "%s\%s" % (TZKEYNAME, name)) - keydict = valuestodict(tzkey) - tzkey.Close() - handle.Close() - - self._stdname = keydict["Std"].encode("iso-8859-1") - self._dstname = keydict["Dlt"].encode("iso-8859-1") - - self._display = keydict["Display"] - - # See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm - tup = struct.unpack("=3l16h", keydict["TZI"]) - self._stdoffset = -tup[0]-tup[1] # Bias + StandardBias * -1 - self._dstoffset = self._stdoffset-tup[2] # + DaylightBias * -1 - - (self._stdmonth, - self._stddayofweek, # Sunday = 0 - self._stdweeknumber, # Last = 5 - self._stdhour, - self._stdminute) = tup[4:9] - - (self._dstmonth, - self._dstdayofweek, # Sunday = 0 - self._dstweeknumber, # Last = 5 - self._dsthour, - self._dstminute) = tup[12:17] - - def __repr__(self): - return "tzwin(%s)" % repr(self._name) - - def __reduce__(self): - return (self.__class__, (self._name,)) - - -class tzwinlocal(tzwinbase): - - def __init__(self): - - handle = _winreg.ConnectRegistry(None, _winreg.HKEY_LOCAL_MACHINE) - - tzlocalkey = _winreg.OpenKey(handle, TZLOCALKEYNAME) - keydict = valuestodict(tzlocalkey) - tzlocalkey.Close() - - self._stdname = keydict["StandardName"].encode("iso-8859-1") - self._dstname = keydict["DaylightName"].encode("iso-8859-1") - - try: - tzkey = _winreg.OpenKey(handle, "%s\%s"%(TZKEYNAME, self._stdname)) - _keydict = valuestodict(tzkey) - self._display = _keydict["Display"] - tzkey.Close() - except OSError: - self._display = None - - handle.Close() - - self._stdoffset = -keydict["Bias"]-keydict["StandardBias"] - self._dstoffset = self._stdoffset-keydict["DaylightBias"] - - - # See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm - tup = struct.unpack("=8h", keydict["StandardStart"]) - - (self._stdmonth, - self._stddayofweek, # Sunday = 0 - self._stdweeknumber, # Last = 5 - self._stdhour, - self._stdminute) = tup[1:6] - - tup = struct.unpack("=8h", keydict["DaylightStart"]) - - (self._dstmonth, - self._dstdayofweek, # Sunday = 0 - self._dstweeknumber, # Last = 5 - self._dsthour, - self._dstminute) = tup[1:6] - - def __reduce__(self): - return (self.__class__, ()) - -def picknthweekday(year, month, dayofweek, hour, minute, whichweek): - """dayofweek == 0 means Sunday, whichweek 5 means last instance""" - first = datetime.datetime(year, month, 1, hour, minute) - weekdayone = first.replace(day=((dayofweek-first.isoweekday())%7+1)) - for n in xrange(whichweek): - dt = weekdayone+(whichweek-n)*ONEWEEK - if dt.month == month: - return dt - -def valuestodict(key): - """Convert a registry key's values to a dictionary.""" - dict = {} - size = _winreg.QueryInfoKey(key)[1] - for i in range(size): - data = _winreg.EnumValue(key, i) - dict[data[0]] = data[1] - return dict +# tzwin has moved to dateutil.tz.win +from .tz.win import * diff --git a/libs/dateutil/utils.py b/libs/dateutil/utils.py new file mode 100644 index 00000000..ebcce6aa --- /dev/null +++ b/libs/dateutil/utils.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +This module offers general convenience and utility functions for dealing with +datetimes. + +.. versionadded:: 2.7.0 +""" +from __future__ import unicode_literals + +from datetime import datetime, time + + +def today(tzinfo=None): + """ + Returns a :py:class:`datetime` representing the current day at midnight + + :param tzinfo: + The time zone to attach (also used to determine the current day). + + :return: + A :py:class:`datetime.datetime` object representing the current day + at midnight. + """ + + dt = datetime.now(tzinfo) + return datetime.combine(dt.date(), time(0, tzinfo=tzinfo)) + + +def default_tzinfo(dt, tzinfo): + """ + Sets the the ``tzinfo`` parameter on naive datetimes only + + This is useful for example when you are provided a datetime that may have + either an implicit or explicit time zone, such as when parsing a time zone + string. + + .. doctest:: + + >>> from dateutil.tz import tzoffset + >>> from dateutil.parser import parse + >>> from dateutil.utils import default_tzinfo + >>> dflt_tz = tzoffset("EST", -18000) + >>> print(default_tzinfo(parse('2014-01-01 12:30 UTC'), dflt_tz)) + 2014-01-01 12:30:00+00:00 + >>> print(default_tzinfo(parse('2014-01-01 12:30'), dflt_tz)) + 2014-01-01 12:30:00-05:00 + + :param dt: + The datetime on which to replace the time zone + + :param tzinfo: + The :py:class:`datetime.tzinfo` subclass instance to assign to + ``dt`` if (and only if) it is naive. + + :return: + Returns an aware :py:class:`datetime.datetime`. + """ + if dt.tzinfo is not None: + return dt + else: + return dt.replace(tzinfo=tzinfo) + + +def within_delta(dt1, dt2, delta): + """ + Useful for comparing two datetimes that may a negilible difference + to be considered equal. + """ + delta = abs(delta) + difference = dt1 - dt2 + return -delta <= difference <= delta diff --git a/libs/dateutil/zoneinfo/__init__.py b/libs/dateutil/zoneinfo/__init__.py index 9bed6264..34f11ad6 100644 --- a/libs/dateutil/zoneinfo/__init__.py +++ b/libs/dateutil/zoneinfo/__init__.py @@ -1,87 +1,167 @@ -""" -Copyright (c) 2003-2005 Gustavo Niemeyer +# -*- coding: utf-8 -*- +import warnings +import json -This module offers extensions to the standard python 2.3+ -datetime module. -""" -from dateutil.tz import tzfile from tarfile import TarFile -import os +from pkgutil import get_data +from io import BytesIO -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" +from dateutil.tz import tzfile as _tzfile -__all__ = ["setcachesize", "gettz", "rebuild"] +__all__ = ["get_zonefile_instance", "gettz", "gettz_db_metadata"] -CACHE = [] -CACHESIZE = 10 +ZONEFILENAME = "dateutil-zoneinfo.tar.gz" +METADATA_FN = 'METADATA' -class tzfile(tzfile): + +class tzfile(_tzfile): def __reduce__(self): return (gettz, (self._filename,)) -def getzoneinfofile(): - filenames = os.listdir(os.path.join(os.path.dirname(__file__))) - filenames.sort() - filenames.reverse() - for entry in filenames: - if entry.startswith("zoneinfo") and ".tar." in entry: - return os.path.join(os.path.dirname(__file__), entry) - return None -ZONEINFOFILE = getzoneinfofile() +def getzoneinfofile_stream(): + try: + return BytesIO(get_data(__name__, ZONEFILENAME)) + except IOError as e: # TODO switch to FileNotFoundError? + warnings.warn("I/O error({0}): {1}".format(e.errno, e.strerror)) + return None -del getzoneinfofile -def setcachesize(size): - global CACHESIZE, CACHE - CACHESIZE = size - del CACHE[size:] +class ZoneInfoFile(object): + def __init__(self, zonefile_stream=None): + if zonefile_stream is not None: + with TarFile.open(fileobj=zonefile_stream) as tf: + self.zones = {zf.name: tzfile(tf.extractfile(zf), filename=zf.name) + for zf in tf.getmembers() + if zf.isfile() and zf.name != METADATA_FN} + # deal with links: They'll point to their parent object. Less + # waste of memory + links = {zl.name: self.zones[zl.linkname] + for zl in tf.getmembers() if + zl.islnk() or zl.issym()} + self.zones.update(links) + try: + metadata_json = tf.extractfile(tf.getmember(METADATA_FN)) + metadata_str = metadata_json.read().decode('UTF-8') + self.metadata = json.loads(metadata_str) + except KeyError: + # no metadata in tar file + self.metadata = None + else: + self.zones = {} + self.metadata = None + + def get(self, name, default=None): + """ + Wrapper for :func:`ZoneInfoFile.zones.get`. This is a convenience method + for retrieving zones from the zone dictionary. + + :param name: + The name of the zone to retrieve. (Generally IANA zone names) + + :param default: + The value to return in the event of a missing key. + + .. versionadded:: 2.6.0 + + """ + return self.zones.get(name, default) + + +# The current API has gettz as a module function, although in fact it taps into +# a stateful class. So as a workaround for now, without changing the API, we +# will create a new "global" class instance the first time a user requests a +# timezone. Ugly, but adheres to the api. +# +# TODO: Remove after deprecation period. +_CLASS_ZONE_INSTANCE = [] + + +def get_zonefile_instance(new_instance=False): + """ + This is a convenience function which provides a :class:`ZoneInfoFile` + instance using the data provided by the ``dateutil`` package. By default, it + caches a single instance of the ZoneInfoFile object and returns that. + + :param new_instance: + If ``True``, a new instance of :class:`ZoneInfoFile` is instantiated and + used as the cached instance for the next call. Otherwise, new instances + are created only as necessary. + + :return: + Returns a :class:`ZoneInfoFile` object. + + .. versionadded:: 2.6 + """ + if new_instance: + zif = None + else: + zif = getattr(get_zonefile_instance, '_cached_instance', None) + + if zif is None: + zif = ZoneInfoFile(getzoneinfofile_stream()) + + get_zonefile_instance._cached_instance = zif + + return zif + def gettz(name): - tzinfo = None - if ZONEINFOFILE: - for cachedname, tzinfo in CACHE: - if cachedname == name: - break - else: - tf = TarFile.open(ZONEINFOFILE) - try: - zonefile = tf.extractfile(name) - except KeyError: - tzinfo = None - else: - tzinfo = tzfile(zonefile) - tf.close() - CACHE.insert(0, (name, tzinfo)) - del CACHE[CACHESIZE:] - return tzinfo + """ + This retrieves a time zone from the local zoneinfo tarball that is packaged + with dateutil. -def rebuild(filename, tag=None, format="gz"): - import tempfile, shutil - tmpdir = tempfile.mkdtemp() - zonedir = os.path.join(tmpdir, "zoneinfo") - moduledir = os.path.dirname(__file__) - if tag: tag = "-"+tag - targetname = "zoneinfo%s.tar.%s" % (tag, format) - try: - tf = TarFile.open(filename) - for name in tf.getnames(): - if not (name.endswith(".sh") or - name.endswith(".tab") or - name == "leapseconds"): - tf.extract(name, tmpdir) - filepath = os.path.join(tmpdir, name) - os.system("zic -d %s %s" % (zonedir, filepath)) - tf.close() - target = os.path.join(moduledir, targetname) - for entry in os.listdir(moduledir): - if entry.startswith("zoneinfo") and ".tar." in entry: - os.unlink(os.path.join(moduledir, entry)) - tf = TarFile.open(target, "w:%s" % format) - for entry in os.listdir(zonedir): - entrypath = os.path.join(zonedir, entry) - tf.add(entrypath, entry) - tf.close() - finally: - shutil.rmtree(tmpdir) + :param name: + An IANA-style time zone name, as found in the zoneinfo file. + + :return: + Returns a :class:`dateutil.tz.tzfile` time zone object. + + .. warning:: + It is generally inadvisable to use this function, and it is only + provided for API compatibility with earlier versions. This is *not* + equivalent to ``dateutil.tz.gettz()``, which selects an appropriate + time zone based on the inputs, favoring system zoneinfo. This is ONLY + for accessing the dateutil-specific zoneinfo (which may be out of + date compared to the system zoneinfo). + + .. deprecated:: 2.6 + If you need to use a specific zoneinfofile over the system zoneinfo, + instantiate a :class:`dateutil.zoneinfo.ZoneInfoFile` object and call + :func:`dateutil.zoneinfo.ZoneInfoFile.get(name)` instead. + + Use :func:`get_zonefile_instance` to retrieve an instance of the + dateutil-provided zoneinfo. + """ + warnings.warn("zoneinfo.gettz() will be removed in future versions, " + "to use the dateutil-provided zoneinfo files, instantiate a " + "ZoneInfoFile object and use ZoneInfoFile.zones.get() " + "instead. See the documentation for details.", + DeprecationWarning) + + if len(_CLASS_ZONE_INSTANCE) == 0: + _CLASS_ZONE_INSTANCE.append(ZoneInfoFile(getzoneinfofile_stream())) + return _CLASS_ZONE_INSTANCE[0].zones.get(name) + + +def gettz_db_metadata(): + """ Get the zonefile metadata + + See `zonefile_metadata`_ + + :returns: + A dictionary with the database metadata + + .. deprecated:: 2.6 + See deprecation warning in :func:`zoneinfo.gettz`. To get metadata, + query the attribute ``zoneinfo.ZoneInfoFile.metadata``. + """ + warnings.warn("zoneinfo.gettz_db_metadata() will be removed in future " + "versions, to use the dateutil-provided zoneinfo files, " + "ZoneInfoFile object and query the 'metadata' attribute " + "instead. See the documentation for details.", + DeprecationWarning) + + if len(_CLASS_ZONE_INSTANCE) == 0: + _CLASS_ZONE_INSTANCE.append(ZoneInfoFile(getzoneinfofile_stream())) + return _CLASS_ZONE_INSTANCE[0].metadata diff --git a/libs/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz b/libs/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6e8c05efd4f06dcb3b18cd919438255cd71ace47 GIT binary patch literal 154226 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 z zZ?S>7+Ew2O(mhBL66cLV>4`6K%NBWTj<-0^T-~5=vckk>+3_>d%G`Z&AeQ9rN66QA6~NOA-2yK zui2p~w2u&9F-NfO8pQo%`R)$*XS||ie`Ek+d;?*vf$YZvPg@qu%!`XdY+tSy2d;2D zW?$E7*$8AHy(D^?@-dbbn026}Qk}K+>5&oe_Dh{NKhaK-%fv&9?vbE;`3l63FX>UW z(fPKKYZ%1(j>YOhE?S!mwu)aBQt^+jDl+=I`CKjlf4hd($+ z&R^QFfcQ+RK^+$h_J-TShkIRB(ONa9-x@IIOQJVb$6O0_T~pQPL)E0h?WwFI_h4*@3^D& z6wCu5ajon0yekM8=^=K=C~2DHBmEo9cN_b&H&Hp_(T5h>($WcwNp(iM8~E|Gcbn`_ zV)LLPcvQ;qkd%OX_&9ddIOdN<(}RxEgKPv5_NbzX@f>2E!GYDEsW1|YU6Y?G)TPau zbr$a(>)z@9nRS$l*B*H}uxqIbF~BU8d0@aqHJT5jnPs?dj+#WT=p9 zb(=F5Y}FNgcEYf>YGCEQG&J+ssikaohw=7D1SugO%Iq>a6Ci&TO3CwP;$0J=hH)ry3~bZ<}8`*iG*!!H%J*V2?j(` z_R+9ApXRCr{kDjYU_b4>0iBGBE54_a%nC>B@w1@}C=4dOn{Yzmt@mGq?BSBL-C#!K zwf%Q#+dD5YP4ynaxhkJ~iPe3!J^*P1APonk19oWzyDl+{{Certy|-{;@8L(}i2`?N zvw+|{P-Dw29K{oAX>Wx=7}FRUCI4(_gqFunMqPZD+3Thmtl0&>WPstpY*p9#FL^0!Rl=`CY#U)II_gMZ)}BVj@*?sk&wPdlDZU=An?S2|D?+nXHR|q0TRE+x+=?rL5Az+i0@VkF7iMXg zbMFg@>fbX-5R!0MnRq%?1>RhwJ=W7^%dzvXHjUm|De3lHQ4QU0O-KANpMy`I&d;2) z>S?d%DZxcqlwHSnbi=LNWA`#1RplRy)q3bned>}biC`Z(*g7yBw2a~0hfSQMD(P55lPaf9>;#&lz!=%(z5Dw+p4c5sy4Zp)P_E-rl?LOZLeM_ zg-}aI$i6O#XQ4;oHZr1Y<(EXrB_%H&{dZ-QZ(O#9eIkn}NzcoRpBo<56bh<~$}Cnv zAr^gW^{PzvU79ZsMH2JxL#;W+@1#*N& zp*Hj{5y&prZxkN1%1$ zsb~4JXQY)rvAnQ^R@ER^AWY(%X()O)>VNt%OO}=1`(Fa>o|AM^oFgk42I7S@{x$tPxzQF-&xUo7lyeFbZYH) zhMM~o4K)cQX@~k*+Q4`N%k7pBX83Hda6~{cSZdHtFplx1yT?fO4Swyw*q{{+< zD_ZW#HfM01qjHQT1h&ZP7yZjHU3wmE)7B2us95b$c!b$JCh4X|c!Ka2H9x&Qrqpwg zDRneVc+q)bud18!7X4f)hUq^++O=*<4A)>aq9_QdBc2q0h#D1R4?RZqCU*Gr`OjWU zH}O5rJ?vgv;7`;l;;?-n0Wn{k$P!xATlp1<5O(y_u9BV7eiT0 z>z!HQInI9)bAF}vc~L|WcffmPqKFKD_^I%m0GT=IQcu2bXU*2iQ)!^^l;}SiLCRJ# z^E@+5q0ar%C^O6i!93aT1mT=4hg3_w`AI-Pr<*X|Bp^FKU3f~aHdI>3KR?&(Lqar+ zw9G#m8UgY2RCa7@gDKV8aA_?d;QbcB0RkF8!15nW7FD@M=dTD(`@}{QXiFwQvqN}F zuQs$K!V;$v=eNLj4-_g{iS$$_e4xezsL2LIq9oGYqTcu%0Ds;L(-T>++UB?H_oXPH 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 Rs}CAN=Sk9x^OX$ee*qyL&5r;8 literal 0 HcmV?d00001 diff --git a/libs/dateutil/zoneinfo/rebuild.py b/libs/dateutil/zoneinfo/rebuild.py new file mode 100644 index 00000000..78f0d1a0 --- /dev/null +++ b/libs/dateutil/zoneinfo/rebuild.py @@ -0,0 +1,53 @@ +import logging +import os +import tempfile +import shutil +import json +from subprocess import check_call +from tarfile import TarFile + +from dateutil.zoneinfo import METADATA_FN, ZONEFILENAME + + +def rebuild(filename, tag=None, format="gz", zonegroups=[], metadata=None): + """Rebuild the internal timezone info in dateutil/zoneinfo/zoneinfo*tar* + + filename is the timezone tarball from ``ftp.iana.org/tz``. + + """ + tmpdir = tempfile.mkdtemp() + zonedir = os.path.join(tmpdir, "zoneinfo") + moduledir = os.path.dirname(__file__) + try: + with TarFile.open(filename) as tf: + for name in zonegroups: + tf.extract(name, tmpdir) + filepaths = [os.path.join(tmpdir, n) for n in zonegroups] + try: + check_call(["zic", "-d", zonedir] + filepaths) + except OSError as e: + _print_on_nosuchfile(e) + raise + # write metadata file + with open(os.path.join(zonedir, METADATA_FN), 'w') as f: + json.dump(metadata, f, indent=4, sort_keys=True) + target = os.path.join(moduledir, ZONEFILENAME) + with TarFile.open(target, "w:%s" % format) as tf: + for entry in os.listdir(zonedir): + entrypath = os.path.join(zonedir, entry) + tf.add(entrypath, entry) + finally: + shutil.rmtree(tmpdir) + + +def _print_on_nosuchfile(e): + """Print helpful troubleshooting message + + e is an exception raised by subprocess.check_call() + + """ + if e.errno == 2: + logging.error( + "Could not find zic. Perhaps you need to install " + "libc-bin or some other package that provides it, " + "or it's not in your PATH?") diff --git a/libs/dateutil/zoneinfo/zoneinfo-2010g.tar.gz b/libs/dateutil/zoneinfo/zoneinfo-2010g.tar.gz deleted file mode 100644 index 8bd4f96402be50779e4b2749688d077347a6eef0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 171995 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

*(x6q- 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 31: + if _is_int(groups[0]) and int(groups[0][:2]) > 31: return False # If match ends with a short year, then day_first is force to true. - elif _is_int(groups[-1]) and int(groups[-1][-2:]) > 31: + if _is_int(groups[-1]) and int(groups[-1][-2:]) > 31: return True -def search_date(string, year_first=None, day_first=None): +def search_date(string, year_first=None, day_first=None): # pylint:disable=inconsistent-return-statements """Looks for date patterns, and if found return the date and group span. Assumes there are sentinels at the beginning and end of the string that @@ -84,42 +84,42 @@ def search_date(string, year_first=None, day_first=None): >>> search_date(' no date in here ') """ - start, end = None, None - match = None - groups = None for date_re in date_regexps: search_match = date_re.search(string) - if search_match and (match is None or search_match.end() - search_match.start() > len(match)): - start, end = search_match.start(1), search_match.end(1) - groups = search_match.groups()[1:] - match = '-'.join(groups) + if not search_match: + continue - if match is None: - return + start, end = search_match.start(1), search_match.end(1) + groups = search_match.groups()[1:] + match = '-'.join(groups) - if year_first and day_first is None: - day_first = False + if match is None: + continue - if day_first is None: - day_first = _guess_day_first_parameter(groups) + if year_first and day_first is None: + day_first = False - # If day_first/year_first is undefined, parse is made using both possible values. - yearfirst_opts = [False, True] - if year_first is not None: - yearfirst_opts = [year_first] + if day_first is None: + day_first = _guess_day_first_parameter(groups) - dayfirst_opts = [True, False] - if day_first is not None: - dayfirst_opts = [day_first] + # If day_first/year_first is undefined, parse is made using both possible values. + yearfirst_opts = [False, True] + if year_first is not None: + yearfirst_opts = [year_first] - kwargs_list = ({'dayfirst': d, 'yearfirst': y} for d in dayfirst_opts for y in yearfirst_opts) - for kwargs in kwargs_list: - try: - date = parser.parse(match, **kwargs) - except (ValueError, TypeError): # pragma: no cover - # see https://bugs.launchpad.net/dateutil/+bug/1247643 - date = None + dayfirst_opts = [True, False] + if day_first is not None: + dayfirst_opts = [day_first] - # check date plausibility - if date and valid_year(date.year): # pylint:disable=no-member - return start, end, date.date() # pylint:disable=no-member + kwargs_list = ({'dayfirst': d, 'yearfirst': y} + for d in dayfirst_opts for y in yearfirst_opts) + for kwargs in kwargs_list: + try: + date = parser.parse(match, **kwargs) + except (ValueError, TypeError): # pragma: no cover + # see https://bugs.launchpad.net/dateutil/+bug/1247643 + date = None + + # check date plausibility + if date and valid_year(date.year): # pylint:disable=no-member + return start, end, date.date() # pylint:disable=no-member diff --git a/libs/guessit/rules/common/expected.py b/libs/guessit/rules/common/expected.py new file mode 100644 index 00000000..eae562a2 --- /dev/null +++ b/libs/guessit/rules/common/expected.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Expected property factory +""" +import re + +from rebulk import Rebulk +from rebulk.utils import find_all + +from . import dash, seps + + +def build_expected_function(context_key): + """ + Creates a expected property function + :param context_key: + :type context_key: + :param cleanup: + :type cleanup: + :return: + :rtype: + """ + + def expected(input_string, context): + """ + Expected property functional pattern. + :param input_string: + :type input_string: + :param context: + :type context: + :return: + :rtype: + """ + ret = [] + for search in context.get(context_key): + if search.startswith('re:'): + search = search[3:] + search = search.replace(' ', '-') + matches = Rebulk().regex(search, abbreviations=[dash], flags=re.IGNORECASE) \ + .matches(input_string, context) + for match in matches: + ret.append(match.span) + else: + value = search + for sep in seps: + input_string = input_string.replace(sep, ' ') + search = search.replace(sep, ' ') + for start in find_all(input_string, search, ignore_case=True): + ret.append({'start': start, 'end': start + len(search), 'value': value}) + return ret + + return expected diff --git a/libs/guessit/rules/common/formatters.py b/libs/guessit/rules/common/formatters.py index 6bd09b15..434c20be 100644 --- a/libs/guessit/rules/common/formatters.py +++ b/libs/guessit/rules/common/formatters.py @@ -25,7 +25,7 @@ def _potential_before(i, input_string): :return: :rtype: bool """ - return i - 2 >= 0 and input_string[i] == input_string[i - 2] and input_string[i - 1] not in seps + return i - 2 >= 0 and input_string[i] in seps and input_string[i - 2] in seps and input_string[i - 1] not in seps def _potential_after(i, input_string): diff --git a/libs/guessit/rules/common/pattern.py b/libs/guessit/rules/common/pattern.py new file mode 100644 index 00000000..5f560f2c --- /dev/null +++ b/libs/guessit/rules/common/pattern.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Pattern utility functions +""" + + +def is_disabled(context, name): + """Whether a specific pattern is disabled. + + The context object might define an inclusion list (includes) or an exclusion list (excludes) + A pattern is considered disabled if it's found in the exclusion list or + it's not found in the inclusion list and the inclusion list is not empty or not defined. + + :param context: + :param name: + :return: + """ + if not context: + return False + + excludes = context.get('excludes') + if excludes and name in excludes: + return True + + includes = context.get('includes') + return includes and name not in includes diff --git a/libs/guessit/rules/common/quantity.py b/libs/guessit/rules/common/quantity.py new file mode 100644 index 00000000..bbd41fbb --- /dev/null +++ b/libs/guessit/rules/common/quantity.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Quantities: Size +""" +import re +from abc import abstractmethod + +import six + +from ..common import seps + + +class Quantity(object): + """ + Represent a quantity object with magnitude and units. + """ + + parser_re = re.compile(r'(?P\d+(?:[.]\d+)?)(?P[^\d]+)?') + + def __init__(self, magnitude, units): + self.magnitude = magnitude + self.units = units + + @classmethod + @abstractmethod + def parse_units(cls, value): + """ + Parse a string to a proper unit notation. + """ + raise NotImplementedError + + @classmethod + def fromstring(cls, string): + """ + Parse the string into a quantity object. + :param string: + :return: + """ + values = cls.parser_re.match(string).groupdict() + try: + magnitude = int(values['magnitude']) + except ValueError: + magnitude = float(values['magnitude']) + units = cls.parse_units(values['units']) + + return cls(magnitude, units) + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + if isinstance(other, six.string_types): + return str(self) == other + if not isinstance(other, self.__class__): + return NotImplemented + return self.magnitude == other.magnitude and self.units == other.units + + def __ne__(self, other): + return not self == other + + def __repr__(self): + return '<{0} [{1}]>'.format(self.__class__.__name__, self) + + def __str__(self): + return '{0}{1}'.format(self.magnitude, self.units) + + +class Size(Quantity): + """ + Represent size. + + e.g.: 1.1GB, 300MB + """ + + @classmethod + def parse_units(cls, value): + return value.strip(seps).upper() + + +class BitRate(Quantity): + """ + Represent bit rate. + + e.g.: 320Kbps, 1.5Mbps + """ + + @classmethod + def parse_units(cls, value): + value = value.strip(seps).capitalize() + for token in ('bits', 'bit'): + value = value.replace(token, 'bps') + + return value + + +class FrameRate(Quantity): + """ + Represent frame rate. + + e.g.: 24fps, 60fps + """ + + @classmethod + def parse_units(cls, value): + return 'fps' diff --git a/libs/guessit/rules/common/words.py b/libs/guessit/rules/common/words.py index b73b1eef..cccbc7d2 100644 --- a/libs/guessit/rules/common/words.py +++ b/libs/guessit/rules/common/words.py @@ -5,7 +5,7 @@ Words utils """ from collections import namedtuple -from guessit.rules.common import seps +from . import seps _Word = namedtuple('_Word', ['span', 'value']) @@ -32,46 +32,3 @@ def iter_words(string): i += 1 if inside_word: yield _Word(span=(last_sep_index+1, i), value=string[last_sep_index+1:i]) - - -# list of common words which could be interpreted as properties, but which -# are far too common to be able to say they represent a property in the -# middle of a string (where they most likely carry their commmon meaning) -COMMON_WORDS = frozenset([ - # english words - 'is', 'it', 'am', 'mad', 'men', 'man', 'run', 'sin', 'st', 'to', - 'no', 'non', 'war', 'min', 'new', 'car', 'day', 'bad', 'bat', 'fan', - 'fry', 'cop', 'zen', 'gay', 'fat', 'one', 'cherokee', 'got', 'an', 'as', - 'cat', 'her', 'be', 'hat', 'sun', 'may', 'my', 'mr', 'rum', 'pi', 'bb', - 'bt', 'tv', 'aw', 'by', 'md', 'mp', 'cd', 'lt', 'gt', 'in', 'ad', 'ice', - 'ay', 'at', 'star', 'so', 'he', 'do', 'ax', 'mx', - # french words - 'bas', 'de', 'le', 'son', 'ne', 'ca', 'ce', 'et', 'que', - 'mal', 'est', 'vol', 'or', 'mon', 'se', 'je', 'tu', 'me', - 'ne', 'ma', 'va', 'au', 'lu', - # japanese words, - 'wa', 'ga', 'ao', - # spanish words - 'la', 'el', 'del', 'por', 'mar', 'al', - # other - 'ind', 'arw', 'ts', 'ii', 'bin', 'chan', 'ss', 'san', 'oss', 'iii', - 'vi', 'ben', 'da', 'lt', 'ch', 'sr', 'ps', 'cx', 'vo', - # new from babelfish - 'mkv', 'avi', 'dmd', 'the', 'dis', 'cut', 'stv', 'des', 'dia', 'and', - 'cab', 'sub', 'mia', 'rim', 'las', 'une', 'par', 'srt', 'ano', 'toy', - 'job', 'gag', 'reel', 'www', 'for', 'ayu', 'csi', 'ren', 'moi', 'sur', - 'fer', 'fun', 'two', 'big', 'psy', 'air', - # movie title - 'brazil', 'jordan', - # release groups - 'bs', # Bosnian - 'kz', - # countries - 'gt', 'lt', 'im', - # part/pt - 'pt', - # screener - 'scr', - # quality - 'sd', 'hr' -]) diff --git a/libs/guessit/rules/markers/groups.py b/libs/guessit/rules/markers/groups.py index bbe69d1c..4716d15d 100644 --- a/libs/guessit/rules/markers/groups.py +++ b/libs/guessit/rules/markers/groups.py @@ -6,17 +6,20 @@ Groups markers (...), [...] and {...} from rebulk import Rebulk -def groups(): +def groups(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ rebulk = Rebulk() rebulk.defaults(name="group", marker=True) - starting = '([{' - ending = ')]}' + starting = config['starting'] + ending = config['ending'] def mark_groups(input_string): """ diff --git a/libs/guessit/rules/markers/path.py b/libs/guessit/rules/markers/path.py index 5e487ea6..6d993b75 100644 --- a/libs/guessit/rules/markers/path.py +++ b/libs/guessit/rules/markers/path.py @@ -8,9 +8,12 @@ from rebulk import Rebulk from rebulk.utils import find_all -def path(): +def path(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ @@ -22,6 +25,7 @@ def path(): Functional pattern to mark path elements. :param input_string: + :param context: :return: """ ret = [] diff --git a/libs/guessit/rules/processors.py b/libs/guessit/rules/processors.py index 3480a9d1..cced26a5 100644 --- a/libs/guessit/rules/processors.py +++ b/libs/guessit/rules/processors.py @@ -9,19 +9,17 @@ import copy import six from rebulk import Rebulk, Rule, CustomRule, POST_PROCESS, PRE_PROCESS, AppendMatch, RemoveMatch -from guessit.rules.common.words import iter_words + +from .common import seps_no_groups from .common.formatters import cleanup from .common.comparators import marker_sorted from .common.date import valid_year +from .common.words import iter_words class EnlargeGroupMatches(CustomRule): """ Enlarge matches that are starting and/or ending group to include brackets in their span. - :param matches: - :type matches: - :return: - :rtype: """ priority = PRE_PROCESS @@ -36,8 +34,7 @@ class EnlargeGroupMatches(CustomRule): for match in matches.ending(group.end - 1): ending.append(match) - if starting or ending: - return starting, ending + return starting, ending def then(self, matches, when_response, context): starting, ending = when_response @@ -89,21 +86,27 @@ class EquivalentHoles(Rule): class RemoveAmbiguous(Rule): """ - If multiple match are found with same name and different values, keep the one in the most valuable filepart. + If multiple matches are found with same name and different values, keep the one in the most valuable filepart. Also keep others match with same name and values than those kept ones. """ + priority = POST_PROCESS consequence = RemoveMatch + def __init__(self, sort_function=marker_sorted, predicate=None): + super(RemoveAmbiguous, self).__init__() + self.sort_function = sort_function + self.predicate = predicate + def when(self, matches, context): - fileparts = marker_sorted(matches.markers.named('path'), matches) + fileparts = self.sort_function(matches.markers.named('path'), matches) previous_fileparts_names = set() values = defaultdict(list) to_remove = [] for filepart in fileparts: - filepart_matches = matches.range(filepart.start, filepart.end) + filepart_matches = matches.range(filepart.start, filepart.end, predicate=self.predicate) filepart_names = set() for match in filepart_matches: @@ -120,6 +123,19 @@ class RemoveAmbiguous(Rule): return to_remove +class RemoveLessSpecificSeasonEpisode(RemoveAmbiguous): + """ + If multiple season/episodes matches are found with different values, + keep the one tagged as 'SxxExx' or in the rightmost filepart. + """ + def __init__(self, name): + super(RemoveLessSpecificSeasonEpisode, self).__init__( + sort_function=(lambda markers, matches: + marker_sorted(list(reversed(markers)), matches, + lambda match: match.name == name and 'SxxExx' in match.tags)), + predicate=lambda match: match.name == name) + + def _preferred_string(value1, value2): # pylint:disable=too-many-return-statements """ Retrieves preferred title from both values. @@ -176,6 +192,23 @@ class SeasonYear(Rule): return ret +class YearSeason(Rule): + """ + If a year is found, no season found, and episode is found, create an match with season. + """ + priority = POST_PROCESS + consequence = AppendMatch + + def when(self, matches, context): + ret = [] + if not matches.named('season') and matches.named('episode'): + for year in matches.named('year'): + season = copy.copy(year) + season.name = 'season' + ret.append(season) + return ret + + class Processors(CustomRule): """ Empty rule for ordering post_processing properly. @@ -189,10 +222,36 @@ class Processors(CustomRule): pass -def processors(): +class StripSeparators(CustomRule): + """ + Strip separators from matches. Keep separators if they are from acronyms, like in ".S.H.I.E.L.D." + """ + priority = POST_PROCESS + + def when(self, matches, context): + return matches + + def then(self, matches, when_response, context): # pragma: no cover + for match in matches: + for _ in range(0, len(match.span)): + if match.raw[0] in seps_no_groups and (len(match.raw) < 3 or match.raw[2] not in seps_no_groups): + match.raw_start += 1 + + for _ in reversed(range(0, len(match.span))): + if match.raw[-1] in seps_no_groups and (len(match.raw) < 3 or match.raw[-3] not in seps_no_groups): + match.raw_end -= 1 + + +def processors(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - return Rebulk().rules(EnlargeGroupMatches, EquivalentHoles, RemoveAmbiguous, SeasonYear, Processors) + return Rebulk().rules(EnlargeGroupMatches, EquivalentHoles, + RemoveLessSpecificSeasonEpisode('season'), + RemoveLessSpecificSeasonEpisode('episode'), + RemoveAmbiguous, SeasonYear, YearSeason, Processors, StripSeparators) diff --git a/libs/guessit/rules/properties/audio_codec.py b/libs/guessit/rules/properties/audio_codec.py index c88a6e7e..a2566bce 100644 --- a/libs/guessit/rules/properties/audio_codec.py +++ b/libs/guessit/rules/properties/audio_codec.py @@ -6,15 +6,20 @@ audio_codec, audio_profile and audio_channels property from rebulk.remodule import re from rebulk import Rebulk, Rule, RemoveMatch + from ..common import dash +from ..common.pattern import is_disabled from ..common.validators import seps_before, seps_after audio_properties = ['audio_codec', 'audio_profile', 'audio_channels'] -def audio_codec(): +def audio_codec(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ @@ -36,34 +41,49 @@ def audio_codec(): return match1 return '__default__' - rebulk.defaults(name="audio_codec", conflict_solver=audio_codec_priority) + rebulk.defaults(name='audio_codec', + conflict_solver=audio_codec_priority, + disabled=lambda context: is_disabled(context, 'audio_codec')) rebulk.regex("MP3", "LAME", r"LAME(?:\d)+-?(?:\d)+", value="MP3") - rebulk.regex("Dolby", "DolbyDigital", "Dolby-Digital", "DD", value="DolbyDigital") - rebulk.regex("DolbyAtmos", "Dolby-Atmos", "Atmos", value="DolbyAtmos") - rebulk.regex("AAC", value="AAC") - rebulk.regex("AC3D?", value="AC3") - rebulk.regex("Flac", value="FLAC") - rebulk.regex("DTS", value="DTS") - rebulk.regex("True-?HD", value="TrueHD") + rebulk.string("MP2", value="MP2") + rebulk.regex('Dolby', 'DolbyDigital', 'Dolby-Digital', 'DD', 'AC3D?', value='Dolby Digital') + rebulk.regex('Dolby-?Atmos', 'Atmos', value='Dolby Atmos') + rebulk.string("AAC", value="AAC") + rebulk.string('EAC3', 'DDP', 'DD+', value='Dolby Digital Plus') + rebulk.string("Flac", value="FLAC") + rebulk.string("DTS", value="DTS") + rebulk.regex('DTS-?HD', 'DTS(?=-?MA)', value='DTS-HD', + conflict_solver=lambda match, other: other if other.name == 'audio_codec' else '__default__') + rebulk.regex('True-?HD', value='Dolby TrueHD') + rebulk.string('Opus', value='Opus') + rebulk.string('Vorbis', value='Vorbis') + rebulk.string('PCM', value='PCM') + rebulk.string('LPCM', value='LPCM') - rebulk.defaults(name="audio_profile") - rebulk.string("HD", value="HD", tags="DTS") - rebulk.regex("HD-?MA", value="HDMA", tags="DTS") - rebulk.string("HE", value="HE", tags="AAC") - rebulk.string("LC", value="LC", tags="AAC") - rebulk.string("HQ", value="HQ", tags="AC3") + rebulk.defaults(name='audio_profile', disabled=lambda context: is_disabled(context, 'audio_profile')) + rebulk.string('MA', value='Master Audio', tags=['audio_profile.rule', 'DTS-HD']) + rebulk.string('HR', 'HRA', value='High Resolution Audio', tags=['audio_profile.rule', 'DTS-HD']) + rebulk.string('ES', value='Extended Surround', tags=['audio_profile.rule', 'DTS']) + rebulk.string('HE', value='High Efficiency', tags=['audio_profile.rule', 'AAC']) + rebulk.string('LC', value='Low Complexity', tags=['audio_profile.rule', 'AAC']) + rebulk.string('HQ', value='High Quality', tags=['audio_profile.rule', 'Dolby Digital']) + rebulk.string('EX', value='EX', tags=['audio_profile.rule', 'Dolby Digital']) - rebulk.defaults(name="audio_channels") - rebulk.regex(r'(7[\W_][01](?:ch)?)(?:[^\d]|$)', value='7.1', children=True) - rebulk.regex(r'(5[\W_][01](?:ch)?)(?:[^\d]|$)', value='5.1', children=True) - rebulk.regex(r'(2[\W_]0(?:ch)?)(?:[^\d]|$)', value='2.0', children=True) + rebulk.defaults(name="audio_channels", disabled=lambda context: is_disabled(context, 'audio_channels')) + rebulk.regex(r'(7[\W_][01](?:ch)?)(?=[^\d]|$)', value='7.1', children=True) + rebulk.regex(r'(5[\W_][01](?:ch)?)(?=[^\d]|$)', value='5.1', children=True) + rebulk.regex(r'(2[\W_]0(?:ch)?)(?=[^\d]|$)', value='2.0', children=True) + rebulk.regex('7[01]', value='7.1', validator=seps_after, tags='weak-audio_channels') + rebulk.regex('5[01]', value='5.1', validator=seps_after, tags='weak-audio_channels') + rebulk.string('20', value='2.0', validator=seps_after, tags='weak-audio_channels') rebulk.string('7ch', '8ch', value='7.1') rebulk.string('5ch', '6ch', value='5.1') rebulk.string('2ch', 'stereo', value='2.0') rebulk.string('1ch', 'mono', value='1.0') - rebulk.rules(DtsRule, AacRule, Ac3Rule, AudioValidatorRule, HqConflictRule) + rebulk.rules(DtsHDRule, DtsRule, AacRule, DolbyDigitalRule, AudioValidatorRule, HqConflictRule, + AudioChannelsValidatorRule) return rebulk @@ -108,25 +128,49 @@ class AudioProfileRule(Rule): super(AudioProfileRule, self).__init__() self.codec = codec + def enabled(self, context): + return not is_disabled(context, 'audio_profile') + def when(self, matches, context): - profile_list = matches.named('audio_profile', lambda match: self.codec in match.tags) + profile_list = matches.named('audio_profile', + lambda match: 'audio_profile.rule' in match.tags and + self.codec in match.tags) ret = [] for profile in profile_list: - codec = matches.previous(profile, lambda match: match.name == 'audio_codec' and match.value == self.codec) + codec = matches.at_span(profile.span, + lambda match: match.name == 'audio_codec' and + match.value == self.codec, 0) if not codec: - codec = matches.next(profile, lambda match: match.name == 'audio_codec' and match.value == self.codec) + codec = matches.previous(profile, + lambda match: match.name == 'audio_codec' and + match.value == self.codec) + if not codec: + codec = matches.next(profile, + lambda match: match.name == 'audio_codec' and + match.value == self.codec) if not codec: ret.append(profile) + if codec: + ret.extend(matches.conflicting(profile)) return ret +class DtsHDRule(AudioProfileRule): + """ + Rule to validate DTS-HD profile + """ + + def __init__(self): + super(DtsHDRule, self).__init__('DTS-HD') + + class DtsRule(AudioProfileRule): """ Rule to validate DTS profile """ def __init__(self): - super(DtsRule, self).__init__("DTS") + super(DtsRule, self).__init__('DTS') class AacRule(AudioProfileRule): @@ -135,16 +179,16 @@ class AacRule(AudioProfileRule): """ def __init__(self): - super(AacRule, self).__init__("AAC") + super(AacRule, self).__init__('AAC') -class Ac3Rule(AudioProfileRule): +class DolbyDigitalRule(AudioProfileRule): """ - Rule to validate AC3 profile + Rule to validate Dolby Digital profile """ def __init__(self): - super(Ac3Rule, self).__init__("AC3") + super(DolbyDigitalRule, self).__init__('Dolby Digital') class HqConflictRule(Rule): @@ -152,13 +196,35 @@ class HqConflictRule(Rule): Solve conflict between HQ from other property and from audio_profile. """ - dependency = [DtsRule, AacRule, Ac3Rule] + dependency = [DtsHDRule, DtsRule, AacRule, DolbyDigitalRule] consequence = RemoveMatch - def when(self, matches, context): - hq_audio = matches.named('audio_profile', lambda match: match.value == 'HQ') - hq_audio_spans = [match.span for match in hq_audio] - hq_other = matches.named('other', lambda match: match.span in hq_audio_spans) + def enabled(self, context): + return not is_disabled(context, 'audio_profile') - if hq_other: - return hq_other + def when(self, matches, context): + hq_audio = matches.named('audio_profile', lambda m: m.value == 'High Quality') + hq_audio_spans = [match.span for match in hq_audio] + return matches.named('other', lambda m: m.span in hq_audio_spans) + + +class AudioChannelsValidatorRule(Rule): + """ + Remove audio_channel if no audio codec as previous match. + """ + priority = 128 + consequence = RemoveMatch + + def enabled(self, context): + return not is_disabled(context, 'audio_channels') + + def when(self, matches, context): + ret = [] + + for audio_channel in matches.tagged('weak-audio_channels'): + valid_before = matches.range(audio_channel.start - 1, audio_channel.start, + lambda match: match.name == 'audio_codec') + if not valid_before: + ret.append(audio_channel) + + return ret diff --git a/libs/guessit/rules/properties/bit_rate.py b/libs/guessit/rules/properties/bit_rate.py new file mode 100644 index 00000000..391f1d2f --- /dev/null +++ b/libs/guessit/rules/properties/bit_rate.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +video_bit_rate and audio_bit_rate properties +""" +import re + +from rebulk import Rebulk +from rebulk.rules import Rule, RemoveMatch, RenameMatch + +from ..common import dash, seps +from ..common.pattern import is_disabled +from ..common.quantity import BitRate +from ..common.validators import seps_surround + + +def bit_rate(config): # pylint:disable=unused-argument + """ + Builder for rebulk object. + + :param config: rule configuration + :type config: dict + :return: Created Rebulk object + :rtype: Rebulk + """ + rebulk = Rebulk(disabled=lambda context: (is_disabled(context, 'audio_bit_rate') + and is_disabled(context, 'video_bit_rate'))) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) + rebulk.defaults(name='audio_bit_rate', validator=seps_surround) + rebulk.regex(r'\d+-?[kmg]b(ps|its?)', r'\d+\.\d+-?[kmg]b(ps|its?)', + conflict_solver=( + lambda match, other: match + if other.name == 'audio_channels' and 'weak-audio_channels' not in other.tags + else other + ), + formatter=BitRate.fromstring, tags=['release-group-prefix']) + + rebulk.rules(BitRateTypeRule) + + return rebulk + + +class BitRateTypeRule(Rule): + """ + Convert audio bit rate guess into video bit rate. + """ + consequence = [RenameMatch('video_bit_rate'), RemoveMatch] + + def when(self, matches, context): + to_rename = [] + to_remove = [] + + if is_disabled(context, 'audio_bit_rate'): + to_remove.extend(matches.named('audio_bit_rate')) + else: + video_bit_rate_disabled = is_disabled(context, 'video_bit_rate') + for match in matches.named('audio_bit_rate'): + previous = matches.previous(match, index=0, + predicate=lambda m: m.name in ('source', 'screen_size', 'video_codec')) + if previous and not matches.holes(previous.end, match.start, predicate=lambda m: m.value.strip(seps)): + after = matches.next(match, index=0, predicate=lambda m: m.name == 'audio_codec') + if after and not matches.holes(match.end, after.start, predicate=lambda m: m.value.strip(seps)): + bitrate = match.value + if bitrate.units == 'Kbps' or (bitrate.units == 'Mbps' and bitrate.magnitude < 10): + continue + + if video_bit_rate_disabled: + to_remove.append(match) + else: + to_rename.append(match) + + return to_rename, to_remove diff --git a/libs/guessit/rules/properties/bonus.py b/libs/guessit/rules/properties/bonus.py index e37613e9..c4554cd0 100644 --- a/libs/guessit/rules/properties/bonus.py +++ b/libs/guessit/rules/properties/bonus.py @@ -9,21 +9,26 @@ from rebulk import Rebulk, AppendMatch, Rule from .title import TitleFromPosition from ..common.formatters import cleanup +from ..common.pattern import is_disabled from ..common.validators import seps_surround -def bonus(): +def bonus(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'bonus')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE) rebulk.regex(r'x(\d+)', name='bonus', private_parent=True, children=True, formatter=int, validator={'__parent__': lambda match: seps_surround}, conflict_solver=lambda match, conflicting: match - if conflicting.name in ['video_codec', 'episode'] and 'bonus-conflict' not in conflicting.tags + if conflicting.name in ('video_codec', 'episode') and 'weak-episode' not in conflicting.tags else '__default__') rebulk.rules(BonusTitleRule) @@ -40,7 +45,7 @@ class BonusTitleRule(Rule): properties = {'bonus_title': [None]} - def when(self, matches, context): + def when(self, matches, context): # pylint:disable=inconsistent-return-statements bonus_number = matches.named('bonus', lambda match: not match.private, index=0) if bonus_number: filepath = matches.markers.at_match(bonus_number, lambda marker: marker.name == 'path', 0) diff --git a/libs/guessit/rules/properties/cds.py b/libs/guessit/rules/properties/cds.py index db1407d6..873df6fe 100644 --- a/libs/guessit/rules/properties/cds.py +++ b/libs/guessit/rules/properties/cds.py @@ -6,16 +6,22 @@ cd and cd_count properties from rebulk.remodule import re from rebulk import Rebulk + from ..common import dash +from ..common.pattern import is_disabled -def cds(): +def cds(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'cd')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) rebulk.regex(r'cd-?(?P\d+)(?:-?of-?(?P\d+))?', validator={'cd': lambda match: 0 < match.value < 100, diff --git a/libs/guessit/rules/properties/container.py b/libs/guessit/rules/properties/container.py index 747a3ebc..77599509 100644 --- a/libs/guessit/rules/properties/container.py +++ b/libs/guessit/rules/properties/container.py @@ -6,48 +6,55 @@ container property from rebulk.remodule import re from rebulk import Rebulk + +from ..common import seps +from ..common.pattern import is_disabled from ..common.validators import seps_surround from ...reutils import build_or_pattern -def container(): +def container(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE).string_defaults(ignore_case=True) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'container')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE).string_defaults(ignore_case=True) rebulk.defaults(name='container', - formatter=lambda value: value[1:], + formatter=lambda value: value.strip(seps), tags=['extension'], conflict_solver=lambda match, other: other - if other.name in ['format', 'video_codec'] or + if other.name in ('source', 'video_codec') or other.name == 'container' and 'extension' not in other.tags else '__default__') - subtitles = ['srt', 'idx', 'sub', 'ssa', 'ass'] - info = ['nfo'] - videos = ['3g2', '3gp', '3gp2', 'asf', 'avi', 'divx', 'flv', 'm4v', 'mk2', - 'mka', 'mkv', 'mov', 'mp4', 'mp4a', 'mpeg', 'mpg', 'ogg', 'ogm', - 'ogv', 'qt', 'ra', 'ram', 'rm', 'ts', 'wav', 'webm', 'wma', 'wmv', - 'iso', 'vob'] - torrent = ['torrent'] + subtitles = config['subtitles'] + info = config['info'] + videos = config['videos'] + torrent = config['torrent'] + nzb = config['nzb'] rebulk.regex(r'\.'+build_or_pattern(subtitles)+'$', exts=subtitles, tags=['extension', 'subtitle']) rebulk.regex(r'\.'+build_or_pattern(info)+'$', exts=info, tags=['extension', 'info']) rebulk.regex(r'\.'+build_or_pattern(videos)+'$', exts=videos, tags=['extension', 'video']) rebulk.regex(r'\.'+build_or_pattern(torrent)+'$', exts=torrent, tags=['extension', 'torrent']) + rebulk.regex(r'\.'+build_or_pattern(nzb)+'$', exts=nzb, tags=['extension', 'nzb']) rebulk.defaults(name='container', validator=seps_surround, - formatter=lambda s: s.upper(), + formatter=lambda s: s.lower(), conflict_solver=lambda match, other: match - if other.name in ['format', - 'video_codec'] or other.name == 'container' and 'extension' in other.tags + if other.name in ('source', + 'video_codec') or other.name == 'container' and 'extension' in other.tags else '__default__') - rebulk.string(*[sub for sub in subtitles if sub not in ['sub']], tags=['subtitle']) + rebulk.string(*[sub for sub in subtitles if sub not in ('sub', 'ass')], tags=['subtitle']) rebulk.string(*videos, tags=['video']) rebulk.string(*torrent, tags=['torrent']) + rebulk.string(*nzb, tags=['nzb']) return rebulk diff --git a/libs/guessit/rules/properties/country.py b/libs/guessit/rules/properties/country.py index 8f03b498..172c2990 100644 --- a/libs/guessit/rules/properties/country.py +++ b/libs/guessit/rules/properties/country.py @@ -7,41 +7,50 @@ country property import babelfish from rebulk import Rebulk -from ..common.words import COMMON_WORDS, iter_words +from ..common.pattern import is_disabled +from ..common.words import iter_words -def country(): +def country(config, common_words): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict + :param common_words: common words + :type common_words: set :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().defaults(name='country') + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'country')) + rebulk = rebulk.defaults(name='country') + + def find_countries(string, context=None): + """ + Find countries in given string. + """ + allowed_countries = context.get('allowed_countries') if context else None + return CountryFinder(allowed_countries, common_words).find(string) rebulk.functional(find_countries, #  Prefer language and any other property over country if not US or GB. conflict_solver=lambda match, other: match - if other.name != 'language' or match.value not in [babelfish.Country('US'), - babelfish.Country('GB')] + if other.name != 'language' or match.value not in (babelfish.Country('US'), + babelfish.Country('GB')) else other, - properties={'country': [None]}) + properties={'country': [None]}, + disabled=lambda context: not context.get('allowed_countries')) + + babelfish.country_converters['guessit'] = GuessitCountryConverter(config['synonyms']) return rebulk -COUNTRIES_SYN = {'ES': ['españa'], - 'GB': ['UK'], - 'BR': ['brazilian', 'bra'], - 'CA': ['québec', 'quebec', 'qc'], - # FIXME: this one is a bit of a stretch, not sure how to do it properly, though... - 'MX': ['Latinoamérica', 'latin america']} - - class GuessitCountryConverter(babelfish.CountryReverseConverter): # pylint: disable=missing-docstring - def __init__(self): + def __init__(self, synonyms): self.guessit_exceptions = {} - for alpha2, synlist in COUNTRIES_SYN.items(): + for alpha2, synlist in synonyms.items(): for syn in synlist: self.guessit_exceptions[syn.lower()] = alpha2 @@ -56,7 +65,7 @@ class GuessitCountryConverter(babelfish.CountryReverseConverter): # pylint: dis return 'UK' return str(babelfish.Country(alpha2)) - def reverse(self, name): + def reverse(self, name): # pylint:disable=arguments-differ # exceptions come first, as they need to override a potential match # with any of the other guessers try: @@ -78,32 +87,28 @@ class GuessitCountryConverter(babelfish.CountryReverseConverter): # pylint: dis raise babelfish.CountryReverseError(name) -babelfish.country_converters['guessit'] = GuessitCountryConverter() +class CountryFinder(object): + """Helper class to search and return country matches.""" + def __init__(self, allowed_countries, common_words): + self.allowed_countries = {l.lower() for l in allowed_countries or []} + self.common_words = common_words -def is_allowed_country(country_object, context=None): - """ - Check if country is allowed. - """ - if context and context.get('allowed_countries'): - allowed_countries = context.get('allowed_countries') - return country_object.name.lower() in allowed_countries or country_object.alpha2.lower() in allowed_countries - return True + def find(self, string): + """Return all matches for country.""" + for word_match in iter_words(string.strip().lower()): + word = word_match.value + if word.lower() in self.common_words: + continue + try: + country_object = babelfish.Country.fromguessit(word) + if (country_object.name.lower() in self.allowed_countries or + country_object.alpha2.lower() in self.allowed_countries): + yield self._to_rebulk_match(word_match, country_object) + except babelfish.Error: + continue -def find_countries(string, context=None): - """ - Find countries in given string. - """ - ret = [] - for word_match in iter_words(string.strip().lower()): - word = word_match.value - if word.lower() in COMMON_WORDS: - continue - try: - country_object = babelfish.Country.fromguessit(word) - if is_allowed_country(country_object, context): - ret.append((word_match.span[0], word_match.span[1], {'value': country_object})) - except babelfish.Error: - continue - return ret + @classmethod + def _to_rebulk_match(cls, word, value): + return word.span[0], word.span[1], {'value': value} diff --git a/libs/guessit/rules/properties/crc.py b/libs/guessit/rules/properties/crc.py index f655bc13..eedee93d 100644 --- a/libs/guessit/rules/properties/crc.py +++ b/libs/guessit/rules/properties/crc.py @@ -6,20 +6,25 @@ crc and uuid properties from rebulk.remodule import re from rebulk import Rebulk +from ..common.pattern import is_disabled from ..common.validators import seps_surround -def crc(): +def crc(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'crc32')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE) rebulk.defaults(validator=seps_surround) rebulk.regex('(?:[a-fA-F]|[0-9]){8}', name='crc32', - conflict_solver=lambda match, other: match + conflict_solver=lambda match, other: other if other.name in ['episode', 'season'] else '__default__') diff --git a/libs/guessit/rules/properties/date.py b/libs/guessit/rules/properties/date.py index 0b6083bd..e50cdfa3 100644 --- a/libs/guessit/rules/properties/date.py +++ b/libs/guessit/rules/properties/date.py @@ -6,21 +6,29 @@ date and year properties from rebulk import Rebulk, RemoveMatch, Rule from ..common.date import search_date, valid_year +from ..common.pattern import is_disabled from ..common.validators import seps_surround -def date(): +def date(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ rebulk = Rebulk().defaults(validator=seps_surround) rebulk.regex(r"\d{4}", name="year", formatter=int, + disabled=lambda context: is_disabled(context, 'year'), + conflict_solver=lambda match, other: other + if other.name in ('episode', 'season') and len(other.raw) < len(match.raw) + else '__default__', validator=lambda match: seps_surround(match) and valid_year(match.value)) - def date_functional(string, context): + def date_functional(string, context): # pylint:disable=inconsistent-return-statements """ Search for date in the string and retrieves match @@ -33,8 +41,9 @@ def date(): return ret[0], ret[1], {'value': ret[2]} rebulk.functional(date_functional, name="date", properties={'date': [None]}, + disabled=lambda context: is_disabled(context, 'date'), conflict_solver=lambda match, other: other - if other.name in ['episode', 'season'] + if other.name in ('episode', 'season', 'crc32') else '__default__') rebulk.rules(KeepMarkedYearInFilepart) @@ -49,6 +58,9 @@ class KeepMarkedYearInFilepart(Rule): priority = 64 consequence = RemoveMatch + def enabled(self, context): + return not is_disabled(context, 'year') + def when(self, matches, context): ret = [] if len(matches.named('year')) > 1: diff --git a/libs/guessit/rules/properties/edition.py b/libs/guessit/rules/properties/edition.py index 429ba8d3..822aa4ee 100644 --- a/libs/guessit/rules/properties/edition.py +++ b/libs/guessit/rules/properties/edition.py @@ -7,25 +7,46 @@ from rebulk.remodule import re from rebulk import Rebulk from ..common import dash +from ..common.pattern import is_disabled from ..common.validators import seps_surround -def edition(): +def edition(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'edition')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) rebulk.defaults(name='edition', validator=seps_surround) - rebulk.regex('collector', 'collector-edition', 'edition-collector', value='Collector Edition') - rebulk.regex('special-edition', 'edition-special', value='Special Edition', + rebulk.regex('collector', "collector'?s?-edition", 'edition-collector', value='Collector') + rebulk.regex('special-edition', 'edition-special', value='Special', conflict_solver=lambda match, other: other if other.name == 'episode_details' and other.value == 'Special' else '__default__') - rebulk.regex('criterion-edition', 'edition-criterion', value='Criterion Edition') - rebulk.regex('deluxe', 'deluxe-edition', 'edition-deluxe', value='Deluxe Edition') - rebulk.regex('director\'?s?-cut', 'director\'?s?-cut-edition', 'edition-director\'?s?-cut', value='Director\'s cut') + rebulk.string('se', value='Special', tags='has-neighbor') + rebulk.string('ddc', value="Director's Definitive Cut") + rebulk.regex('criterion-edition', 'edition-criterion', 'CC', value='Criterion') + rebulk.regex('deluxe', 'deluxe-edition', 'edition-deluxe', value='Deluxe') + rebulk.regex('limited', 'limited-edition', value='Limited', tags=['has-neighbor', 'release-group-prefix']) + rebulk.regex(r'theatrical-cut', r'theatrical-edition', r'theatrical', value='Theatrical') + rebulk.regex(r"director'?s?-cut", r"director'?s?-cut-edition", r"edition-director'?s?-cut", 'DC', + value="Director's Cut") + rebulk.regex('extended', 'extended-?cut', 'extended-?version', + value='Extended', tags=['has-neighbor', 'release-group-prefix']) + rebulk.regex('alternat(e|ive)(?:-?Cut)?', value='Alternative Cut', tags=['has-neighbor', 'release-group-prefix']) + for value in ('Remastered', 'Uncensored', 'Uncut', 'Unrated'): + rebulk.string(value, value=value, tags=['has-neighbor', 'release-group-prefix']) + rebulk.string('Festival', value='Festival', tags=['has-neighbor-before', 'has-neighbor-after']) + rebulk.regex('imax', 'imax-edition', value='IMAX') + rebulk.regex('fan-edit(?:ion)?', 'fan-collection', value='Fan') + rebulk.regex('ultimate-edition', value='Ultimate') + rebulk.regex("ultimate-collector'?s?-edition", value=['Ultimate', 'Collector']) + rebulk.regex('ultimate-fan-edit(?:ion)?', 'ultimate-fan-collection', value=['Ultimate', 'Fan']) return rebulk diff --git a/libs/guessit/rules/properties/episode_title.py b/libs/guessit/rules/properties/episode_title.py index 9d6e4abf..d429c3e7 100644 --- a/libs/guessit/rules/properties/episode_title.py +++ b/libs/guessit/rules/properties/episode_title.py @@ -5,26 +5,92 @@ Episode title """ from collections import defaultdict -from rebulk import Rebulk, Rule, AppendMatch, RenameMatch +from rebulk import Rebulk, Rule, AppendMatch, RemoveMatch, RenameMatch, POST_PROCESS + from ..common import seps, title_seps -from ..properties.title import TitleFromPosition, TitleBaseRule from ..common.formatters import cleanup +from ..common.pattern import is_disabled +from ..properties.title import TitleFromPosition, TitleBaseRule +from ..properties.type import TypeProcessor -def episode_title(): +def episode_title(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().rules(EpisodeTitleFromPosition, - AlternativeTitleReplace, - TitleToEpisodeTitle, - Filepart3EpisodeTitle, - Filepart2EpisodeTitle) + previous_names = ('episode', 'episode_count', + 'season', 'season_count', 'date', 'title', 'year') + + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'episode_title')) + rebulk = rebulk.rules(RemoveConflictsWithEpisodeTitle(previous_names), + EpisodeTitleFromPosition(previous_names), + AlternativeTitleReplace(previous_names), + TitleToEpisodeTitle, + Filepart3EpisodeTitle, + Filepart2EpisodeTitle, + RenameEpisodeTitleWhenMovieType) return rebulk +class RemoveConflictsWithEpisodeTitle(Rule): + """ + Remove conflicting matches that might lead to wrong episode_title parsing. + """ + + priority = 64 + consequence = RemoveMatch + + def __init__(self, previous_names): + super(RemoveConflictsWithEpisodeTitle, self).__init__() + self.previous_names = previous_names + self.next_names = ('streaming_service', 'screen_size', 'source', + 'video_codec', 'audio_codec', 'other', 'container') + self.affected_if_holes_after = ('part', ) + self.affected_names = ('part', 'year') + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + for match in matches.range(filepart.start, filepart.end, + predicate=lambda m: m.name in self.affected_names): + before = matches.range(filepart.start, match.start, predicate=lambda m: not m.private, index=-1) + if not before or before.name not in self.previous_names: + continue + + after = matches.range(match.end, filepart.end, predicate=lambda m: not m.private, index=0) + if not after or after.name not in self.next_names: + continue + + group = matches.markers.at_match(match, predicate=lambda m: m.name == 'group', index=0) + + def has_value_in_same_group(current_match, current_group=group): + """Return true if current match has value and belongs to the current group.""" + return current_match.value.strip(seps) and ( + current_group == matches.markers.at_match(current_match, + predicate=lambda mm: mm.name == 'group', index=0) + ) + + holes_before = matches.holes(before.end, match.start, predicate=has_value_in_same_group) + holes_after = matches.holes(match.end, after.start, predicate=has_value_in_same_group) + + if not holes_before and not holes_after: + continue + + if match.name in self.affected_if_holes_after and not holes_after: + continue + + to_remove.append(match) + if match.parent: + to_remove.append(match.parent) + + return to_remove + + class TitleToEpisodeTitle(Rule): """ If multiple different title are found, convert the one following episode number to episode_title. @@ -33,24 +99,19 @@ class TitleToEpisodeTitle(Rule): def when(self, matches, context): titles = matches.named('title') - - if len(titles) < 2: - return - title_groups = defaultdict(list) for title in titles: title_groups[title.value].append(title) episode_titles = [] - main_titles = [] + if len(title_groups) < 2: + return episode_titles + for title in titles: if matches.previous(title, lambda match: match.name == 'episode'): episode_titles.append(title) - else: - main_titles.append(title) - if episode_titles: - return episode_titles + return episode_titles def then(self, matches, when_response, context): for title in when_response: @@ -66,12 +127,14 @@ class EpisodeTitleFromPosition(TitleBaseRule): """ dependency = TitleToEpisodeTitle + def __init__(self, previous_names): + super(EpisodeTitleFromPosition, self).__init__('episode_title', ['title']) + self.previous_names = previous_names + def hole_filter(self, hole, matches): episode = matches.previous(hole, lambda previous: any(name in previous.names - for name in ['episode', 'episode_details', - 'episode_count', 'season', 'season_count', - 'date', 'title', 'year']), + for name in self.previous_names), 0) crc32 = matches.named('crc32') @@ -89,10 +152,7 @@ class EpisodeTitleFromPosition(TitleBaseRule): return False return super(EpisodeTitleFromPosition, self).should_remove(match, matches, filepart, hole, context) - def __init__(self): - super(EpisodeTitleFromPosition, self).__init__('episode_title', ['title']) - - def when(self, matches, context): + def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.named('episode_title'): return return super(EpisodeTitleFromPosition, self).when(matches, context) @@ -105,7 +165,11 @@ class AlternativeTitleReplace(Rule): dependency = EpisodeTitleFromPosition consequence = RenameMatch - def when(self, matches, context): + def __init__(self, previous_names): + super(AlternativeTitleReplace, self).__init__() + self.previous_names = previous_names + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.named('episode_title'): return @@ -116,10 +180,7 @@ class AlternativeTitleReplace(Rule): if main_title: episode = matches.previous(main_title, lambda previous: any(name in previous.names - for name in ['episode', 'episode_details', - 'episode_count', 'season', - 'season_count', - 'date', 'title', 'year']), + for name in self.previous_names), 0) crc32 = matches.named('crc32') @@ -130,9 +191,31 @@ class AlternativeTitleReplace(Rule): def then(self, matches, when_response, context): matches.remove(when_response) when_response.name = 'episode_title' + when_response.tags.append('alternative-replaced') matches.append(when_response) +class RenameEpisodeTitleWhenMovieType(Rule): + """ + Rename episode_title by alternative_title when type is movie. + """ + priority = POST_PROCESS + + dependency = TypeProcessor + consequence = RenameMatch + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements + if matches.named('episode_title', lambda m: 'alternative-replaced' not in m.tags) \ + and not matches.named('type', lambda m: m.value == 'episode'): + return matches.named('episode_title') + + def then(self, matches, when_response, context): + for match in when_response: + matches.remove(match) + match.name = 'alternative_title' + matches.append(match) + + class Filepart3EpisodeTitle(Rule): """ If we have at least 3 filepart structured like this: @@ -140,12 +223,18 @@ class Filepart3EpisodeTitle(Rule): Serie name/SO1/E01-episode_title.mkv AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC + Serie name/SO1/episode_title-E01.mkv + AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC + If CCCC contains episode and BBB contains seasonNumber Then title is to be found in AAAA. """ consequence = AppendMatch('title') - def when(self, matches, context): + def when(self, matches, context): # pylint:disable=inconsistent-return-statements + if matches.tagged('filepart-title'): + return + fileparts = matches.markers.named('path') if len(fileparts) < 3: return @@ -160,6 +249,7 @@ class Filepart3EpisodeTitle(Rule): if season: hole = matches.holes(subdirectory.start, subdirectory.end, + ignore=lambda match: 'weak-episode' in match.tags, formatter=cleanup, seps=title_seps, predicate=lambda match: match.value, index=0) if hole: @@ -174,11 +264,22 @@ class Filepart2EpisodeTitle(Rule): AAAAAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB If BBBB contains episode and AAA contains a hole followed by seasonNumber - Then title is to be found in AAAA. + then title is to be found in AAAA. + + or + + Serie name/SO1E01-episode_title.mkv + AAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB + + If BBBB contains season and episode and AAA contains a hole + then title is to be found in AAAA. """ consequence = AppendMatch('title') - def when(self, matches, context): + def when(self, matches, context): # pylint:disable=inconsistent-return-statements + if matches.tagged('filepart-title'): + return + fileparts = matches.markers.named('path') if len(fileparts) < 2: return @@ -188,9 +289,12 @@ class Filepart2EpisodeTitle(Rule): episode_number = matches.range(filename.start, filename.end, lambda match: match.name == 'episode', 0) if episode_number: - season = matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0) + season = (matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0) or + matches.range(filename.start, filename.end, lambda match: match.name == 'season', 0)) if season: - hole = matches.holes(directory.start, directory.end, formatter=cleanup, seps=title_seps, + hole = matches.holes(directory.start, directory.end, ignore=lambda match: 'weak-episode' in match.tags, + formatter=cleanup, seps=title_seps, predicate=lambda match: match.value, index=0) if hole: + hole.tags.append('filepart-title') return hole diff --git a/libs/guessit/rules/properties/episodes.py b/libs/guessit/rules/properties/episodes.py index 65722835..97f060a2 100644 --- a/libs/guessit/rules/properties/episodes.py +++ b/libs/guessit/rules/properties/episodes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -episode, season, episode_count, season_count and episode_details properties +episode, season, disc, episode_count, season_count and episode_details properties """ import copy from collections import defaultdict @@ -12,23 +12,52 @@ from rebulk.remodule import re from rebulk.utils import is_iterable from .title import TitleFromPosition -from ..common import dash, alt_dash, seps +from ..common import dash, alt_dash, seps, seps_no_fs from ..common.formatters import strip from ..common.numeral import numeral, parse_numeral +from ..common.pattern import is_disabled from ..common.validators import compose, seps_surround, seps_before, int_coercable from ...reutils import build_or_pattern -def episodes(): +def episodes(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ # pylint: disable=too-many-branches,too-many-statements,too-many-locals - rebulk = Rebulk() - rebulk.regex_defaults(flags=re.IGNORECASE).string_defaults(ignore_case=True) - rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator']) + def is_season_episode_disabled(context): + """Whether season and episode rules should be enabled.""" + return is_disabled(context, 'episode') or is_disabled(context, 'season') + + rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE).string_defaults(ignore_case=True) + rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator', 'episodeMarker', 'seasonMarker']) + + episode_max_range = config['episode_max_range'] + season_max_range = config['season_max_range'] + + def episodes_season_chain_breaker(matches): + """ + Break chains if there's more than 100 offset between two neighbor values. + :param matches: + :type matches: + :return: + :rtype: + """ + eps = matches.named('episode') + if len(eps) > 1 and abs(eps[-1].value - eps[-2].value) > episode_max_range: + return True + + seasons = matches.named('season') + if len(seasons) > 1 and abs(seasons[-1].value - seasons[-2].value) > season_max_range: + return True + return False + + rebulk.chain_defaults(chain_breaker=episodes_season_chain_breaker) def season_episode_conflict_solver(match, other): """ @@ -38,38 +67,41 @@ def episodes(): :param other: :return: """ - if match.name in ['season', 'episode'] and other.name in ['screen_size', 'video_codec', - 'audio_codec', 'audio_channels', - 'container', 'date']: - return match - elif match.name in ['season', 'episode'] and other.name in ['season', 'episode'] \ - and match.initiator != other.initiator: - if 'weak-episode' in match.tags: + if match.name != other.name: + if match.name == 'episode' and other.name == 'year': return match - if 'weak-episode' in other.tags: - return other - if 'x' in match.initiator.raw.lower(): - return match - if 'x' in other.initiator.raw.lower(): - return other + if match.name in ('season', 'episode'): + if other.name in ('video_codec', 'audio_codec', 'container', 'date'): + return match + if (other.name == 'audio_channels' and 'weak-audio_channels' not in other.tags + and not match.initiator.children.named(match.name + 'Marker')) or ( + other.name == 'screen_size' and not int_coercable(other.raw)): + + return match + if other.name in ('season', 'episode') and match.initiator != other.initiator: + if (match.initiator.name in ('weak_episode', 'weak_duplicate') + and other.initiator.name in ('weak_episode', 'weak_duplicate')): + return '__default__' + for current in (match, other): + if 'weak-episode' in current.tags or 'x' in current.initiator.raw.lower(): + return current return '__default__' - season_episode_seps = [] - season_episode_seps.extend(seps) - season_episode_seps.extend(['x', 'X', 'e', 'E']) - - season_words = ['season', 'saison', 'serie', 'seasons', 'saisons', 'series'] - episode_words = ['episode', 'episodes', 'ep'] - of_words = ['of', 'sur'] - all_words = ['All'] - season_markers = ["S"] - season_ep_markers = ["x"] - episode_markers = ["xE", "Ex", "EP", "E", "x"] - range_separators = ['-', '~', 'to', 'a'] - weak_discrete_separators = list(sep for sep in seps if sep not in range_separators) - strong_discrete_separators = ['+', '&', 'and', 'et'] + season_words = config['season_words'] + episode_words = config['episode_words'] + of_words = config['of_words'] + all_words = config['all_words'] + season_markers = config['season_markers'] + season_ep_markers = config['season_ep_markers'] + disc_markers = config['disc_markers'] + episode_markers = config['episode_markers'] + range_separators = config['range_separators'] + weak_discrete_separators = list(sep for sep in seps_no_fs if sep not in range_separators) + strong_discrete_separators = config['discrete_separators'] discrete_separators = strong_discrete_separators + weak_discrete_separators + max_range_gap = config['max_range_gap'] + def ordering_validator(match): """ Validator for season list. They should be in natural order to be validated. @@ -77,7 +109,7 @@ def episodes(): episode/season separated by a weak discrete separator should be consecutive, unless a strong discrete separator or a range separator is present in the chain (1.3&5 is valid, but 1.3-5 is not valid and 1.3.5 is not valid) """ - values = match.children.to_dict(implicit=True) + values = match.children.to_dict() if 'season' in values and is_iterable(values['season']): # Season numbers must be in natural order to be validated. if not list(sorted(values['season'])) == values['season']: @@ -104,7 +136,7 @@ def episodes(): separator = match.children.previous(current_match, lambda m: m.name == property_name + 'Separator', 0) if separator.raw not in range_separators and separator.raw in weak_discrete_separators: - if not current_match.value - previous_match.value == 1: + if not 0 < current_match.value - previous_match.value <= max_range_gap + 1: valid = False if separator.raw in strong_discrete_separators: valid = True @@ -122,24 +154,25 @@ def episodes(): private_parent=True, validate_all=True, validator={'__parent__': ordering_validator}, - conflict_solver=season_episode_conflict_solver) \ - .regex(build_or_pattern(season_markers) + r'(?P\d+)@?' + - build_or_pattern(episode_markers) + r'@?(?P\d+)', + conflict_solver=season_episode_conflict_solver, + disabled=is_season_episode_disabled) \ + .regex(build_or_pattern(season_markers, name='seasonMarker') + r'(?P\d+)@?' + + build_or_pattern(episode_markers + disc_markers, name='episodeMarker') + r'@?(?P\d+)', validate_all=True, validator={'__parent__': seps_before}).repeater('+') \ - .regex(build_or_pattern(episode_markers + discrete_separators + range_separators, + .regex(build_or_pattern(episode_markers + disc_markers + discrete_separators + range_separators, name='episodeSeparator', escape=True) + r'(?P\d+)').repeater('*') \ .chain() \ .regex(r'(?P\d+)@?' + - build_or_pattern(season_ep_markers) + + build_or_pattern(season_ep_markers, name='episodeMarker') + r'@?(?P\d+)', validate_all=True, validator={'__parent__': seps_before}) \ .chain() \ .regex(r'(?P\d+)@?' + - build_or_pattern(season_ep_markers) + + build_or_pattern(season_ep_markers, name='episodeMarker') + r'@?(?P\d+)', validate_all=True, validator={'__parent__': seps_before}) \ @@ -148,7 +181,7 @@ def episodes(): escape=True) + r'(?P\d+)').repeater('*') \ .chain() \ - .regex(build_or_pattern(season_markers) + r'(?P\d+)', + .regex(build_or_pattern(season_markers, name='seasonMarker') + r'(?P\d+)', validate_all=True, validator={'__parent__': seps_before}) \ .regex(build_or_pattern(season_markers + discrete_separators + range_separators, @@ -157,12 +190,9 @@ def episodes(): r'(?P\d+)').repeater('*') # episode_details property - for episode_detail in ('Special', 'Bonus', 'Omake', 'Ova', 'Oav', 'Pilot', 'Unaired'): - rebulk.string(episode_detail, value=episode_detail, name='episode_details') - rebulk.regex(r'Extras?', name='episode_details', value='Extras') - - rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator'], - validate_all=True, validator={'__parent__': seps_surround}, children=True, private_parent=True) + for episode_detail in ('Special', 'Pilot', 'Unaired', 'Final'): + rebulk.string(episode_detail, value=episode_detail, name='episode_details', + disabled=lambda context: is_disabled(context, 'episode_details')) def validate_roman(match): """ @@ -176,121 +206,219 @@ def episodes(): return True return seps_surround(match) + rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator', 'episodeMarker', 'seasonMarker'], + validate_all=True, validator={'__parent__': seps_surround}, children=True, private_parent=True, + conflict_solver=season_episode_conflict_solver) + rebulk.chain(abbreviations=[alt_dash], formatter={'season': parse_numeral, 'count': parse_numeral}, validator={'__parent__': compose(seps_surround, ordering_validator), 'season': validate_roman, - 'count': validate_roman}) \ + 'count': validate_roman}, + disabled=lambda context: context.get('type') == 'movie' or is_disabled(context, 'season')) \ .defaults(validator=None) \ - .regex(build_or_pattern(season_words) + '@?(?P' + numeral + ')') \ + .regex(build_or_pattern(season_words, name='seasonMarker') + '@?(?P' + numeral + ')') \ .regex(r'' + build_or_pattern(of_words) + '@?(?P' + numeral + ')').repeater('?') \ - .regex(r'@?(?P' + - build_or_pattern(range_separators + discrete_separators + ['@'], escape=True) + - r')@?(?P\d+)').repeater('*') + .regex(r'@?' + build_or_pattern(range_separators + discrete_separators + ['@'], + name='seasonSeparator', escape=True) + + r'@?(?P\d+)').repeater('*') - rebulk.regex(build_or_pattern(episode_words) + r'-?(?P\d+)' + + rebulk.regex(build_or_pattern(episode_words, name='episodeMarker') + r'-?(?P\d+)' + r'(?:v(?P\d+))?' + r'(?:-?' + build_or_pattern(of_words) + r'-?(?P\d+))?', # Episode 4 - abbreviations=[dash], formatter=int, - disabled=lambda context: context.get('type') == 'episode') + abbreviations=[dash], formatter={'episode': int, 'version': int, 'count': int}, + disabled=lambda context: context.get('type') == 'episode' or is_disabled(context, 'episode')) - rebulk.regex(build_or_pattern(episode_words) + r'-?(?P' + numeral + ')' + + rebulk.regex(build_or_pattern(episode_words, name='episodeMarker') + r'-?(?P' + numeral + ')' + r'(?:v(?P\d+))?' + r'(?:-?' + build_or_pattern(of_words) + r'-?(?P\d+))?', # Episode 4 abbreviations=[dash], validator={'episode': validate_roman}, formatter={'episode': parse_numeral, 'version': int, 'count': int}, - disabled=lambda context: context.get('type') != 'episode') + disabled=lambda context: context.get('type') != 'episode' or is_disabled(context, 'episode')) rebulk.regex(r'S?(?P\d+)-?(?:xE|Ex|E|x)-?(?P' + build_or_pattern(all_words) + ')', tags=['SxxExx'], abbreviations=[dash], validator=None, - formatter={'season': int, 'other': lambda match: 'Complete'}) - - rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator'], validate_all=True, - validator={'__parent__': seps_surround}, children=True, private_parent=True) + formatter={'season': int, 'other': lambda match: 'Complete'}, + disabled=lambda context: is_disabled(context, 'season')) # 12, 13 - rebulk.chain(tags=['bonus-conflict', 'weak-movie', 'weak-episode'], formatter={'episode': int, 'version': int}) \ + rebulk.chain(tags=['weak-episode'], formatter={'episode': int, 'version': int}, + disabled=lambda context: context.get('type') == 'movie' or is_disabled(context, 'episode')) \ .defaults(validator=None) \ .regex(r'(?P\d{2})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?P[x-])(?P\d{2})').repeater('*') # 012, 013 - rebulk.chain(tags=['bonus-conflict', 'weak-movie', 'weak-episode'], formatter={'episode': int, 'version': int}) \ + rebulk.chain(tags=['weak-episode'], formatter={'episode': int, 'version': int}, + disabled=lambda context: context.get('type') == 'movie' or is_disabled(context, 'episode')) \ .defaults(validator=None) \ .regex(r'0(?P\d{1,2})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?P[x-])0(?P\d{1,2})').repeater('*') # 112, 113 - rebulk.chain(tags=['bonus-conflict', 'weak-movie', 'weak-episode'], formatter={'episode': int, 'version': int}, - disabled=lambda context: not context.get('episode_prefer_number', False)) \ + rebulk.chain(tags=['weak-episode'], + formatter={'episode': int, 'version': int}, + name='weak_episode', + disabled=lambda context: context.get('type') == 'movie' or is_disabled(context, 'episode')) \ .defaults(validator=None) \ .regex(r'(?P\d{3,4})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?P[x-])(?P\d{3,4})').repeater('*') # 1, 2, 3 - rebulk.chain(tags=['bonus-conflict', 'weak-movie', 'weak-episode'], formatter={'episode': int, 'version': int}, - disabled=lambda context: context.get('type') != 'episode') \ + rebulk.chain(tags=['weak-episode'], formatter={'episode': int, 'version': int}, + disabled=lambda context: context.get('type') != 'episode' or is_disabled(context, 'episode')) \ .defaults(validator=None) \ .regex(r'(?P\d)') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?P[x-])(?P\d{1,2})').repeater('*') - # e112, e113 + # e112, e113, 1e18, 3e19 # TODO: Enhance rebulk for validator to be used globally (season_episode_validator) - rebulk.chain(formatter={'episode': int, 'version': int}) \ + rebulk.chain(formatter={'season': int, 'episode': int, 'version': int}, + disabled=lambda context: is_disabled(context, 'episode')) \ .defaults(validator=None) \ - .regex(r'e(?P\d{1,4})') \ + .regex(r'(?P\d{1,2})?(?Pe)(?P\d{1,4})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?Pe|x|-)(?P\d{1,4})').repeater('*') # ep 112, ep113, ep112, ep113 - rebulk.chain(abbreviations=[dash], formatter={'episode': int, 'version': int}) \ + rebulk.chain(abbreviations=[dash], formatter={'episode': int, 'version': int}, + disabled=lambda context: is_disabled(context, 'episode')) \ .defaults(validator=None) \ .regex(r'ep-?(?P\d{1,4})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?Pep|e|x|-)(?P\d{1,4})').repeater('*') + # cap 112, cap 112_114 + rebulk.chain(abbreviations=[dash], + tags=['see-pattern'], + formatter={'season': int, 'episode': int}, + disabled=is_season_episode_disabled) \ + .defaults(validator=None) \ + .regex(r'(?Pcap)-?(?P\d{1,2})(?P\d{2})') \ + .regex(r'(?P-)(?P\d{1,2})(?P\d{2})').repeater('?') + # 102, 0102 - rebulk.chain(tags=['bonus-conflict', 'weak-movie', 'weak-episode', 'weak-duplicate'], + rebulk.chain(tags=['weak-episode', 'weak-duplicate'], formatter={'season': int, 'episode': int, 'version': int}, - conflict_solver=lambda match, other: match if other.name == 'year' else '__default__', - disabled=lambda context: context.get('episode_prefer_number', False)) \ + name='weak_duplicate', + conflict_solver=season_episode_conflict_solver, + disabled=lambda context: (context.get('episode_prefer_number', False) or + context.get('type') == 'movie') or is_season_episode_disabled(context)) \ .defaults(validator=None) \ .regex(r'(?P\d{1,2})(?P\d{2})') \ .regex(r'v(?P\d+)').repeater('?') \ .regex(r'(?Px|-)(?P\d{2})').repeater('*') - rebulk.regex(r'v(?P\d+)', children=True, private_parent=True, formatter=int) + rebulk.regex(r'v(?P\d+)', children=True, private_parent=True, formatter=int, + disabled=lambda context: is_disabled(context, 'version')) rebulk.defaults(private_names=['episodeSeparator', 'seasonSeparator']) # TODO: List of words # detached of X count (season/episode) - rebulk.regex(r'(?P\d+)?-?' + build_or_pattern(of_words) + + rebulk.regex(r'(?P\d+)-?' + build_or_pattern(of_words) + r'-?(?P\d+)-?' + build_or_pattern(episode_words) + '?', - abbreviations=[dash], children=True, private_parent=True, formatter=int) + abbreviations=[dash], children=True, private_parent=True, formatter=int, + disabled=lambda context: is_disabled(context, 'episode')) - rebulk.regex(r'Minisodes?', name='episode_format', value="Minisode") + rebulk.regex(r'Minisodes?', name='episode_format', value="Minisode", + disabled=lambda context: is_disabled(context, 'episode_format')) - # Harcoded movie to disable weak season/episodes - rebulk.regex('OSS-?117', - abbreviations=[dash], name="hardcoded-movies", marker=True, - conflict_solver=lambda match, other: None) - - rebulk.rules(EpisodeNumberSeparatorRange(range_separators), + rebulk.rules(WeakConflictSolver, RemoveInvalidSeason, RemoveInvalidEpisode, + SeePatternRange(range_separators + ['_']), + EpisodeNumberSeparatorRange(range_separators), SeasonSeparatorRange(range_separators), RemoveWeakIfMovie, RemoveWeakIfSxxExx, RemoveWeakDuplicate, EpisodeDetailValidator, RemoveDetachedEpisodeNumber, VersionValidator, - CountValidator, EpisodeSingleDigitValidator) + RemoveWeak, RenameToAbsoluteEpisode, CountValidator, EpisodeSingleDigitValidator, RenameToDiscMatch) return rebulk +class WeakConflictSolver(Rule): + """ + Rule to decide whether weak-episode or weak-duplicate matches should be kept. + + If an anime is detected: + - weak-duplicate matches should be removed + - weak-episode matches should be tagged as anime + Otherwise: + - weak-episode matches are removed unless they're part of an episode range match. + """ + priority = 128 + consequence = [RemoveMatch, AppendMatch] + + def enabled(self, context): + return context.get('type') != 'movie' + + @classmethod + def is_anime(cls, matches): + """Return True if it seems to be an anime. + + Anime characteristics: + - version, crc32 matches + - screen_size inside brackets + - release_group at start and inside brackets + """ + if matches.named('version') or matches.named('crc32'): + return True + + for group in matches.markers.named('group'): + if matches.range(group.start, group.end, predicate=lambda m: m.name == 'screen_size'): + return True + if matches.markers.starting(group.start, predicate=lambda m: m.name == 'path'): + hole = matches.holes(group.start, group.end, index=0) + if hole and hole.raw == group.raw: + return True + + return False + + def when(self, matches, context): + to_remove = [] + to_append = [] + anime_detected = self.is_anime(matches) + for filepart in matches.markers.named('path'): + weak_matches = matches.range(filepart.start, filepart.end, predicate=( + lambda m: m.initiator.name == 'weak_episode')) + weak_dup_matches = matches.range(filepart.start, filepart.end, predicate=( + lambda m: m.initiator.name == 'weak_duplicate')) + if anime_detected: + if weak_matches: + to_remove.extend(weak_dup_matches) + for match in matches.range(filepart.start, filepart.end, predicate=( + lambda m: m.name == 'episode' and m.initiator.name != 'weak_duplicate')): + episode = copy.copy(match) + episode.tags = episode.tags + ['anime'] + to_append.append(episode) + to_remove.append(match) + elif weak_dup_matches: + episodes_in_range = matches.range(filepart.start, filepart.end, predicate=( + lambda m: + m.name == 'episode' and m.initiator.name == 'weak_episode' + and m.initiator.children.named('episodeSeparator') + )) + if not episodes_in_range and not matches.range(filepart.start, filepart.end, + predicate=lambda m: 'SxxExx' in m.tags): + to_remove.extend(weak_matches) + else: + for match in episodes_in_range: + episode = copy.copy(match) + episode.tags = [] + to_append.append(episode) + to_remove.append(match) + + if to_append: + to_remove.extend(weak_dup_matches) + + return to_remove, to_append + + class CountValidator(Rule): """ Validate count property and rename it @@ -317,6 +445,41 @@ class CountValidator(Rule): return to_remove, episode_count, season_count +class SeePatternRange(Rule): + """ + Create matches for episode range for SEE pattern. E.g.: Cap.102_104 + """ + priority = 128 + consequence = [RemoveMatch, AppendMatch] + + def __init__(self, range_separators): + super(SeePatternRange, self).__init__() + self.range_separators = range_separators + + def when(self, matches, context): + to_remove = [] + to_append = [] + + for separator in matches.tagged('see-pattern', lambda m: m.name == 'episodeSeparator'): + previous_match = matches.previous(separator, lambda m: m.name == 'episode' and 'see-pattern' in m.tags, 0) + next_match = matches.next(separator, lambda m: m.name == 'season' and 'see-pattern' in m.tags, 0) + if not next_match: + continue + + next_match = matches.next(next_match, lambda m: m.name == 'episode' and 'see-pattern' in m.tags, 0) + if previous_match and next_match and separator.value in self.range_separators: + to_remove.append(next_match) + + for episode_number in range(previous_match.value + 1, next_match.value + 1): + match = copy.copy(next_match) + match.value = episode_number + to_append.append(match) + + to_remove.append(separator) + + return to_remove, to_append + + class AbstractSeparatorRange(Rule): """ Remove separator matches and create matches for season range. @@ -334,14 +497,18 @@ class AbstractSeparatorRange(Rule): to_append = [] for separator in matches.named(self.property_name + 'Separator'): - previous_match = matches.previous(separator, lambda match: match.name == self.property_name, 0) - next_match = matches.next(separator, lambda match: match.name == self.property_name, 0) + previous_match = matches.previous(separator, lambda m: m.name == self.property_name, 0) + next_match = matches.next(separator, lambda m: m.name == self.property_name, 0) + initiator = separator.initiator if previous_match and next_match and separator.value in self.range_separators: + to_remove.append(next_match) for episode_number in range(previous_match.value + 1, next_match.value): match = copy.copy(next_match) match.value = episode_number + initiator.children.append(match) to_append.append(match) + to_append.append(next_match) to_remove.append(separator) previous_match = None @@ -351,9 +518,11 @@ class AbstractSeparatorRange(Rule): if separator not in self.range_separators: separator = strip(separator) if separator in self.range_separators: + initiator = previous_match.initiator for episode_number in range(previous_match.value + 1, next_match.value): match = copy.copy(next_match) match.value = episode_number + initiator.children.append(match) to_append.append(match) to_append.append(Match(previous_match.end, next_match.start - 1, name=self.property_name + 'Separator', @@ -367,12 +536,46 @@ class AbstractSeparatorRange(Rule): return to_remove, to_append +class RenameToAbsoluteEpisode(Rule): + """ + Rename episode to absolute_episodes. + + Absolute episodes are only used if two groups of episodes are detected: + S02E04-06 25-27 + 25-27 S02E04-06 + 2x04-06 25-27 + 28. Anime Name S02E05 + The matches in the group with higher episode values are renamed to absolute_episode. + """ + + consequence = RenameMatch('absolute_episode') + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements + initiators = {match.initiator for match in matches.named('episode') + if len(match.initiator.children.named('episode')) > 1} + if len(initiators) != 2: + ret = [] + for filepart in matches.markers.named('path'): + if matches.range(filepart.start + 1, filepart.end, predicate=lambda m: m.name == 'episode'): + ret.extend( + matches.starting(filepart.start, predicate=lambda m: m.initiator.name == 'weak_episode')) + return ret + + initiators = sorted(initiators, key=lambda item: item.end) + if not matches.holes(initiators[0].end, initiators[1].start, predicate=lambda m: m.raw.strip(seps)): + first_range = matches.named('episode', predicate=lambda m: m.initiator == initiators[0]) + second_range = matches.named('episode', predicate=lambda m: m.initiator == initiators[1]) + if len(first_range) == len(second_range): + if second_range[0].value > first_range[0].value: + return second_range + if first_range[0].value > second_range[0].value: + return first_range + + class EpisodeNumberSeparatorRange(AbstractSeparatorRange): """ Remove separator matches and create matches for episoderNumber range. """ - priority = 128 - consequence = [RemoveMatch, AppendMatch] def __init__(self, range_separators): super(EpisodeNumberSeparatorRange, self).__init__(range_separators, "episode") @@ -382,8 +585,6 @@ class SeasonSeparatorRange(AbstractSeparatorRange): """ Remove separator matches and create matches for season range. """ - priority = 128 - consequence = [RemoveMatch, AppendMatch] def __init__(self, range_separators): super(SeasonSeparatorRange, self).__init__(range_separators, "season") @@ -391,26 +592,142 @@ class SeasonSeparatorRange(AbstractSeparatorRange): class RemoveWeakIfMovie(Rule): """ - Remove weak-movie tagged matches if it seems to be a movie. + Remove weak-episode tagged matches if it seems to be a movie. """ priority = 64 consequence = RemoveMatch + def enabled(self, context): + return context.get('type') != 'episode' + def when(self, matches, context): - if matches.named('year') or matches.markers.named('hardcoded-movies'): - return matches.tagged('weak-movie') + to_remove = [] + to_ignore = set() + remove = False + for filepart in matches.markers.named('path'): + year = matches.range(filepart.start, filepart.end, predicate=lambda m: m.name == 'year', index=0) + if year: + remove = True + next_match = matches.range(year.end, filepart.end, predicate=lambda m: m.private, index=0) + if (next_match and not matches.holes(year.end, next_match.start, predicate=lambda m: m.raw.strip(seps)) + and not matches.at_match(next_match, predicate=lambda m: m.name == 'year')): + to_ignore.add(next_match.initiator) + + to_ignore.update(matches.range(filepart.start, filepart.end, + predicate=lambda m: len(m.children.named('episode')) > 1)) + + to_remove.extend(matches.conflicting(year)) + if remove: + to_remove.extend(matches.tagged('weak-episode', predicate=( + lambda m: m.initiator not in to_ignore and 'anime' not in m.tags))) + + return to_remove + + +class RemoveWeak(Rule): + """ + Remove weak-episode matches which appears after video, source, and audio matches. + """ + priority = 16 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + weaks = matches.range(filepart.start, filepart.end, predicate=lambda m: 'weak-episode' in m.tags) + if weaks: + previous = matches.previous(weaks[0], predicate=lambda m: m.name in ( + 'audio_codec', 'screen_size', 'streaming_service', 'source', 'video_profile', + 'audio_channels', 'audio_profile'), index=0) + if previous and not matches.holes( + previous.end, weaks[0].start, predicate=lambda m: m.raw.strip(seps)): + to_remove.extend(weaks) + return to_remove class RemoveWeakIfSxxExx(Rule): """ - Remove weak-movie tagged matches if SxxExx pattern is matched. + Remove weak-episode tagged matches if SxxExx pattern is matched. + + Weak episodes at beginning of filepart are kept. """ priority = 64 consequence = RemoveMatch def when(self, matches, context): - if matches.tagged('SxxExx', lambda match: not match.private): - return matches.tagged('weak-movie') + to_remove = [] + for filepart in matches.markers.named('path'): + if matches.range(filepart.start, filepart.end, + predicate=lambda m: not m.private and 'SxxExx' in m.tags): + for match in matches.range(filepart.start, filepart.end, predicate=lambda m: 'weak-episode' in m.tags): + if match.start != filepart.start or match.initiator.name != 'weak_episode': + to_remove.append(match) + return to_remove + + +class RemoveInvalidSeason(Rule): + """ + Remove invalid season matches. + """ + priority = 64 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + strong_season = matches.range(filepart.start, filepart.end, index=0, + predicate=lambda m: m.name == 'season' + and not m.private and 'SxxExx' in m.tags) + if strong_season: + if strong_season.initiator.children.named('episode'): + for season in matches.range(strong_season.end, filepart.end, + predicate=lambda m: m.name == 'season' and not m.private): + # remove weak season or seasons without episode matches + if 'SxxExx' not in season.tags or not season.initiator.children.named('episode'): + if season.initiator: + to_remove.append(season.initiator) + to_remove.extend(season.initiator.children) + else: + to_remove.append(season) + + return to_remove + + +class RemoveInvalidEpisode(Rule): + """ + Remove invalid episode matches. + """ + priority = 64 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + strong_episode = matches.range(filepart.start, filepart.end, index=0, + predicate=lambda m: m.name == 'episode' + and not m.private and 'SxxExx' in m.tags) + if strong_episode: + strong_ep_marker = RemoveInvalidEpisode.get_episode_prefix(matches, strong_episode) + for episode in matches.range(strong_episode.end, filepart.end, + predicate=lambda m: m.name == 'episode' and not m.private): + ep_marker = RemoveInvalidEpisode.get_episode_prefix(matches, episode) + if strong_ep_marker and ep_marker and strong_ep_marker.value.lower() != ep_marker.value.lower(): + if episode.initiator: + to_remove.append(episode.initiator) + to_remove.extend(episode.initiator.children) + else: + to_remove.append(ep_marker) + to_remove.append(episode) + + return to_remove + + @staticmethod + def get_episode_prefix(matches, episode): + """ + Return episode prefix: episodeMarker or episodeSeparator + """ + return matches.previous(episode, index=0, + predicate=lambda m: m.name in ('episodeMarker', 'episodeSeparator')) class RemoveWeakDuplicate(Rule): @@ -425,7 +742,7 @@ class RemoveWeakDuplicate(Rule): for filepart in matches.markers.named('path'): patterns = defaultdict(list) for match in reversed(matches.range(filepart.start, filepart.end, - predicate=lambda match: 'weak-duplicate' in match.tags)): + predicate=lambda m: 'weak-duplicate' in m.tags)): if match.pattern in patterns[match.name]: to_remove.append(match) else: @@ -465,15 +782,15 @@ class RemoveDetachedEpisodeNumber(Rule): episode_numbers = [] episode_values = set() - for match in matches.named('episode', lambda match: not match.private and 'weak-movie' in match.tags): + for match in matches.named('episode', lambda m: not m.private and 'weak-episode' in m.tags): if match.value not in episode_values: episode_numbers.append(match) episode_values.add(match.value) - episode_numbers = list(sorted(episode_numbers, key=lambda match: match.value)) + episode_numbers = list(sorted(episode_numbers, key=lambda m: m.value)) if len(episode_numbers) > 1 and \ - episode_numbers[0].value < 10 and \ - episode_numbers[1].value - episode_numbers[0].value != 1: + episode_numbers[0].value < 10 and \ + episode_numbers[1].value - episode_numbers[0].value != 1: parent = episode_numbers[0] while parent: # TODO: Add a feature in rebulk to avoid this ... ret.append(parent) @@ -514,3 +831,29 @@ class EpisodeSingleDigitValidator(Rule): if not matches.range(*group.span, predicate=lambda match: match.name == 'title'): ret.append(episode) return ret + + +class RenameToDiscMatch(Rule): + """ + Rename episodes detected with `d` episodeMarkers to `disc`. + """ + + consequence = [RenameMatch('disc'), RenameMatch('discMarker'), RemoveMatch] + + def when(self, matches, context): + discs = [] + markers = [] + to_remove = [] + + disc_disabled = is_disabled(context, 'disc') + + for marker in matches.named('episodeMarker', predicate=lambda m: m.value.lower() == 'd'): + if disc_disabled: + to_remove.append(marker) + to_remove.extend(marker.initiator.children) + continue + + markers.append(marker) + discs.extend(sorted(marker.initiator.children.named('episode'), key=lambda m: m.value)) + + return discs, markers, to_remove diff --git a/libs/guessit/rules/properties/film.py b/libs/guessit/rules/properties/film.py index 21a56d29..3c7e6c0f 100644 --- a/libs/guessit/rules/properties/film.py +++ b/libs/guessit/rules/properties/film.py @@ -3,21 +3,24 @@ """ film property """ +from rebulk import Rebulk, AppendMatch, Rule from rebulk.remodule import re -from rebulk import Rebulk, AppendMatch, Rule from ..common.formatters import cleanup +from ..common.pattern import is_disabled +from ..common.validators import seps_surround -def film(): +def film(config): # pylint:disable=unused-argument """ Builder for rebulk object. :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE) + rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, validate_all=True, validator={'__parent__': seps_surround}) - rebulk.regex(r'f(\d{1,2})', name='film', private_parent=True, children=True, formatter=int) + rebulk.regex(r'f(\d{1,2})', name='film', private_parent=True, children=True, formatter=int, + disabled=lambda context: is_disabled(context, 'film')) rebulk.rules(FilmTitleRule) @@ -32,7 +35,10 @@ class FilmTitleRule(Rule): properties = {'film_title': [None]} - def when(self, matches, context): + def enabled(self, context): + return not is_disabled(context, 'film_title') + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements bonus_number = matches.named('film', lambda match: not match.private, index=0) if bonus_number: filepath = matches.markers.at_match(bonus_number, lambda marker: marker.name == 'path', 0) diff --git a/libs/guessit/rules/properties/format.py b/libs/guessit/rules/properties/format.py deleted file mode 100644 index aa75f824..00000000 --- a/libs/guessit/rules/properties/format.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -format property -""" -from rebulk.remodule import re - -from rebulk import Rebulk, RemoveMatch, Rule -from ..common import dash -from ..common.validators import seps_before, seps_after - - -def format_(): - """ - Builder for rebulk object. - :return: Created Rebulk object - :rtype: Rebulk - """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) - rebulk.defaults(name="format") - - rebulk.regex("VHS", "VHS-?Rip", value="VHS") - rebulk.regex("CAM", "CAM-?Rip", "HD-?CAM", value="Cam") - rebulk.regex("TELESYNC", "TS", "HD-?TS", value="Telesync") - rebulk.regex("WORKPRINT", "WP", value="Workprint") - rebulk.regex("TELECINE", "TC", value="Telecine") - rebulk.regex("PPV", "PPV-?Rip", value="PPV") # Pay Per View - rebulk.regex("SD-?TV", "SD-?TV-?Rip", "Rip-?SD-?TV", "TV-?Rip", - "Rip-?TV", value="TV") # TV is too common to allow matching - rebulk.regex("DVB-?Rip", "DVB", "PD-?TV", value="DVB") - rebulk.regex("DVD", "DVD-?Rip", "VIDEO-?TS", "DVD-?R(?:$|(?!E))", # "DVD-?R(?:$|^E)" => DVD-Real ... - "DVD-?9", "DVD-?5", value="DVD") - - rebulk.regex("HD-?TV", "TV-?RIP-?HD", "HD-?TV-?RIP", "HD-?RIP", value="HDTV") - rebulk.regex("VOD", "VOD-?Rip", value="VOD") - rebulk.regex("WEB-?Rip", value="WEBRip") - rebulk.regex("WEB-?DL", "WEB-?HD", "WEB", value="WEB-DL") - rebulk.regex("HD-?DVD-?Rip", "HD-?DVD", value="HD-DVD") - rebulk.regex("Blu-?ray(?:-?Rip)?", "B[DR]", "B[DR]-?Rip", "BD[59]", "BD25", "BD50", value="BluRay") - - rebulk.rules(ValidateFormat) - - return rebulk - - -class ValidateFormat(Rule): - """ - Validate format with screener property, with video_codec property or separated - """ - priority = 64 - consequence = RemoveMatch - - def when(self, matches, context): - ret = [] - for format_match in matches.named('format'): - if not seps_before(format_match) and \ - not matches.range(format_match.start - 1, format_match.start - 2, - lambda match: match.name == 'other' and match.value == 'Screener'): - ret.append(format_match) - continue - if not seps_after(format_match) and \ - not matches.range(format_match.end, format_match.end + 1, - lambda match: match.name == 'video_codec' or ( - match.name == 'other' and match.value == 'Screener')): - ret.append(format_match) - continue - return ret diff --git a/libs/guessit/rules/properties/language.py b/libs/guessit/rules/properties/language.py index 3476d60a..bcdbda8b 100644 --- a/libs/guessit/rules/properties/language.py +++ b/libs/guessit/rules/properties/language.py @@ -5,59 +5,86 @@ language and subtitle_language properties """ # pylint: disable=no-member import copy +from collections import defaultdict, namedtuple import babelfish - -from rebulk.remodule import re from rebulk import Rebulk, Rule, RemoveMatch, RenameMatch -from ..common.words import iter_words, COMMON_WORDS +from rebulk.remodule import re + +from ..common import seps +from ..common.pattern import is_disabled +from ..common.words import iter_words from ..common.validators import seps_surround -def language(): +def language(config, common_words): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict + :param common_words: common words + :type common_words: set :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk() + subtitle_both = config['subtitle_affixes'] + subtitle_prefixes = sorted(subtitle_both + config['subtitle_prefixes'], key=length_comparator) + subtitle_suffixes = sorted(subtitle_both + config['subtitle_suffixes'], key=length_comparator) + lang_both = config['language_affixes'] + lang_prefixes = sorted(lang_both + config['language_prefixes'], key=length_comparator) + lang_suffixes = sorted(lang_both + config['language_suffixes'], key=length_comparator) + weak_affixes = frozenset(config['weak_affixes']) + + rebulk = Rebulk(disabled=lambda context: (is_disabled(context, 'language') and + is_disabled(context, 'subtitle_language'))) rebulk.string(*subtitle_prefixes, name="subtitle_language.prefix", ignore_case=True, private=True, - validator=seps_surround) + validator=seps_surround, tags=['release-group-prefix'], + disabled=lambda context: is_disabled(context, 'subtitle_language')) rebulk.string(*subtitle_suffixes, name="subtitle_language.suffix", ignore_case=True, private=True, - validator=seps_surround) - rebulk.functional(find_languages, properties={'language': [None]}) - rebulk.rules(SubtitlePrefixLanguageRule, SubtitleSuffixLanguageRule, SubtitleExtensionRule) + validator=seps_surround, + disabled=lambda context: is_disabled(context, 'subtitle_language')) + rebulk.string(*lang_suffixes, name="language.suffix", ignore_case=True, private=True, + validator=seps_surround, tags=['source-suffix'], + disabled=lambda context: is_disabled(context, 'language')) + + def find_languages(string, context=None): + """Find languages in the string + + :return: list of tuple (property, Language, lang_word, word) + """ + return LanguageFinder(context, subtitle_prefixes, subtitle_suffixes, + lang_prefixes, lang_suffixes, weak_affixes).find(string) + + rebulk.functional(find_languages, + properties={'language': [None]}, + disabled=lambda context: not context.get('allowed_languages')) + rebulk.rules(SubtitleExtensionRule, + SubtitlePrefixLanguageRule, + SubtitleSuffixLanguageRule, + RemoveLanguage, + RemoveInvalidLanguages(common_words)) + + babelfish.language_converters['guessit'] = GuessitConverter(config['synonyms']) return rebulk -COMMON_WORDS_STRICT = frozenset(['brazil']) - UNDETERMINED = babelfish.Language('und') -SYN = {('und', None): ['unknown', 'inconnu', 'unk', 'un'], - ('ell', None): ['gr', 'greek'], - ('spa', None): ['esp', 'español'], - ('fra', None): ['français', 'vf', 'vff', 'vfi', 'vfq'], - ('swe', None): ['se'], - ('por', 'BR'): ['po', 'pb', 'pob', 'br', 'brazilian'], - ('cat', None): ['català'], - ('ces', None): ['cz'], - ('ukr', None): ['ua'], - ('zho', None): ['cn'], - ('jpn', None): ['jp'], - ('hrv', None): ['scr'], - ('mul', None): ['multi', 'dl']} # http://scenelingo.wordpress.com/2009/03/24/what-does-dl-mean/ - class GuessitConverter(babelfish.LanguageReverseConverter): # pylint: disable=missing-docstring _with_country_regexp = re.compile(r'(.*)\((.*)\)') _with_country_regexp2 = re.compile(r'(.*)-(.*)') - def __init__(self): + def __init__(self, synonyms): self.guessit_exceptions = {} - for (alpha3, country), synlist in SYN.items(): + for code, synlist in synonyms.items(): + if '_' in code: + (alpha3, country) = code.split('_') + else: + (alpha3, country) = (code, None) for syn in synlist: self.guessit_exceptions[syn.lower()] = (alpha3, country, None) @@ -73,16 +100,8 @@ class GuessitConverter(babelfish.LanguageReverseConverter): # pylint: disable=m def convert(self, alpha3, country=None, script=None): return str(babelfish.Language(alpha3, country, script)) - def reverse(self, name): - with_country = (GuessitConverter._with_country_regexp.match(name) or - GuessitConverter._with_country_regexp2.match(name)) - + def reverse(self, name): # pylint:disable=arguments-differ name = name.lower() - if with_country: - lang = babelfish.Language.fromguessit(with_country.group(1).strip()) - lang.country = babelfish.Country.fromguessit(with_country.group(2).strip()) - return lang.alpha3, lang.country.alpha2 if lang.country else None, lang.script or None - # exceptions come first, as they need to override a potential match # with any of the other guessers try: @@ -94,7 +113,8 @@ class GuessitConverter(babelfish.LanguageReverseConverter): # pylint: disable=m babelfish.Language.fromalpha3b, babelfish.Language.fromalpha2, babelfish.Language.fromname, - babelfish.Language.fromopensubtitles]: + babelfish.Language.fromopensubtitles, + babelfish.Language.fromietf]: try: reverse = conv(name) return reverse.alpha3, reverse.country, reverse.script @@ -104,59 +124,237 @@ class GuessitConverter(babelfish.LanguageReverseConverter): # pylint: disable=m raise babelfish.LanguageReverseError(name) -babelfish.language_converters['guessit'] = GuessitConverter() - -subtitle_both = ['sub', 'subs', 'subbed', 'custom subbed', 'custom subs', 'custom sub', 'customsubbed', 'customsubs', - 'customsub'] -subtitle_prefixes = subtitle_both + ['st', 'vost', 'subforced', 'fansub', 'hardsub'] -subtitle_suffixes = subtitle_both + ['subforced', 'fansub', 'hardsub'] -lang_prefixes = ['true'] - -all_lang_prefixes_suffixes = subtitle_prefixes + subtitle_suffixes + lang_prefixes - - -def find_languages(string, context=None): - """Find languages in the string - - :return: list of tuple (property, Language, lang_word, word) +def length_comparator(value): """ - allowed_languages = context.get('allowed_languages') - common_words = COMMON_WORDS_STRICT if allowed_languages else COMMON_WORDS + Return value length. + """ + return len(value) - matches = [] - for word_match in iter_words(string): - word = word_match.value - start, end = word_match.span - lang_word = word.lower() - key = 'language' - for prefix in subtitle_prefixes: - if lang_word.startswith(prefix): - lang_word = lang_word[len(prefix):] - key = 'subtitle_language' - for suffix in subtitle_suffixes: - if lang_word.endswith(suffix): - lang_word = lang_word[:len(lang_word) - len(suffix)] - key = 'subtitle_language' - for prefix in lang_prefixes: - if lang_word.startswith(prefix): - lang_word = lang_word[len(prefix):] - if lang_word not in common_words and word.lower() not in common_words: - try: - lang = babelfish.Language.fromguessit(lang_word) - match = (start, end, {'name': key, 'value': lang}) - if allowed_languages: - if lang.name.lower() in allowed_languages \ - or lang.alpha2.lower() in allowed_languages \ - or lang.alpha3.lower() in allowed_languages: - matches.append(match) - # Keep language with alpha2 equivalent. Others are probably - # uncommon languages. - elif lang == 'mul' or hasattr(lang, 'alpha2'): - matches.append(match) - except babelfish.Error: - pass - return matches +_LanguageMatch = namedtuple('_LanguageMatch', ['property_name', 'word', 'lang']) + + +class LanguageWord(object): + """ + Extension to the Word namedtuple in order to create compound words. + + E.g.: pt-BR, soft subtitles, custom subs + """ + + def __init__(self, start, end, value, input_string, next_word=None): + self.start = start + self.end = end + self.value = value + self.input_string = input_string + self.next_word = next_word + + @property + def extended_word(self): # pylint:disable=inconsistent-return-statements + """ + Return the extended word for this instance, if any. + """ + if self.next_word: + separator = self.input_string[self.end:self.next_word.start] + next_separator = self.input_string[self.next_word.end:self.next_word.end + 1] + + if (separator == '-' and separator != next_separator) or separator in (' ', '.'): + value = self.input_string[self.start:self.next_word.end].replace('.', ' ') + + return LanguageWord(self.start, self.next_word.end, value, self.input_string, self.next_word.next_word) + + def __repr__(self): + return '<({start},{end}): {value}'.format(start=self.start, end=self.end, value=self.value) + + +def to_rebulk_match(language_match): + """ + Convert language match to rebulk Match: start, end, dict + """ + word = language_match.word + start = word.start + end = word.end + name = language_match.property_name + if language_match.lang == UNDETERMINED: + return start, end, { + 'name': name, + 'value': word.value.lower(), + 'formatter': babelfish.Language, + 'tags': ['weak-language'] + } + + return start, end, { + 'name': name, + 'value': language_match.lang + } + + +class LanguageFinder(object): + """ + Helper class to search and return language matches: 'language' and 'subtitle_language' properties + """ + + def __init__(self, context, + subtitle_prefixes, subtitle_suffixes, + lang_prefixes, lang_suffixes, weak_affixes): + allowed_languages = context.get('allowed_languages') if context else None + self.allowed_languages = {l.lower() for l in allowed_languages or []} + self.weak_affixes = weak_affixes + self.prefixes_map = {} + self.suffixes_map = {} + + if not is_disabled(context, 'subtitle_language'): + self.prefixes_map['subtitle_language'] = subtitle_prefixes + self.suffixes_map['subtitle_language'] = subtitle_suffixes + + self.prefixes_map['language'] = lang_prefixes + self.suffixes_map['language'] = lang_suffixes + + def find(self, string): + """ + Return all matches for language and subtitle_language. + + Undetermined language matches are removed if a regular language is found. + Multi language matches are removed if there are only undetermined language matches + """ + regular_lang_map = defaultdict(set) + undetermined_map = defaultdict(set) + multi_map = defaultdict(set) + + for match in self.iter_language_matches(string): + key = match.property_name + if match.lang == UNDETERMINED: + undetermined_map[key].add(match) + elif match.lang == 'mul': + multi_map[key].add(match) + else: + regular_lang_map[key].add(match) + + for key, values in multi_map.items(): + if key in regular_lang_map or key not in undetermined_map: + for value in values: + yield to_rebulk_match(value) + + for key, values in undetermined_map.items(): + if key not in regular_lang_map: + for value in values: + yield to_rebulk_match(value) + + for values in regular_lang_map.values(): + for value in values: + yield to_rebulk_match(value) + + def iter_language_matches(self, string): + """ + Return language matches for the given string. + """ + candidates = [] + previous = None + for word in iter_words(string): + language_word = LanguageWord(start=word.span[0], end=word.span[1], value=word.value, input_string=string) + if previous: + previous.next_word = language_word + candidates.append(previous) + previous = language_word + if previous: + candidates.append(previous) + + for candidate in candidates: + for match in self.iter_matches_for_candidate(candidate): + yield match + + def iter_matches_for_candidate(self, language_word): + """ + Return language matches for the given candidate word. + """ + tuples = [ + (language_word, language_word.next_word, + self.prefixes_map, + lambda string, prefix: string.startswith(prefix), + lambda string, prefix: string[len(prefix):]), + (language_word.next_word, language_word, + self.suffixes_map, + lambda string, suffix: string.endswith(suffix), + lambda string, suffix: string[:len(string) - len(suffix)]) + ] + + for word, fallback_word, affixes, is_affix, strip_affix in tuples: + if not word: + continue + + match = self.find_match_for_word(word, fallback_word, affixes, is_affix, strip_affix) + if match: + yield match + + match = self.find_language_match_for_word(language_word) + if match: + yield match + + def find_match_for_word(self, word, fallback_word, affixes, is_affix, strip_affix): # pylint:disable=inconsistent-return-statements + """ + Return the language match for the given word and affixes. + """ + for current_word in (word.extended_word, word): + if not current_word: + continue + + word_lang = current_word.value.lower() + + for key, parts in affixes.items(): + for part in parts: + if not is_affix(word_lang, part): + continue + + match = None + value = strip_affix(word_lang, part) + if not value: + if fallback_word and ( + abs(fallback_word.start - word.end) <= 1 or abs(word.start - fallback_word.end) <= 1): + match = self.find_language_match_for_word(fallback_word, key=key) + + if not match and part not in self.weak_affixes: + match = self.create_language_match(key, LanguageWord(current_word.start, current_word.end, + 'und', current_word.input_string)) + else: + match = self.create_language_match(key, LanguageWord(current_word.start, current_word.end, + value, current_word.input_string)) + + if match: + return match + + def find_language_match_for_word(self, word, key='language'): # pylint:disable=inconsistent-return-statements + """ + Return the language match for the given word. + """ + for current_word in (word.extended_word, word): + if current_word: + match = self.create_language_match(key, current_word) + if match: + return match + + def create_language_match(self, key, word): # pylint:disable=inconsistent-return-statements + """ + Create a LanguageMatch for a given word + """ + lang = self.parse_language(word.value.lower()) + + if lang is not None: + return _LanguageMatch(property_name=key, word=word, lang=lang) + + def parse_language(self, lang_word): # pylint:disable=inconsistent-return-statements + """ + Parse the lang_word into a valid Language. + + Multi and Undetermined languages are also valid languages. + """ + try: + lang = babelfish.Language.fromguessit(lang_word) + if ((hasattr(lang, 'name') and lang.name.lower() in self.allowed_languages) or + (hasattr(lang, 'alpha2') and lang.alpha2.lower() in self.allowed_languages) or + lang.alpha3.lower() in self.allowed_languages): + return lang + + except babelfish.Error: + pass class SubtitlePrefixLanguageRule(Rule): @@ -167,6 +365,9 @@ class SubtitlePrefixLanguageRule(Rule): properties = {'subtitle_language': [None]} + def enabled(self, context): + return not is_disabled(context, 'subtitle_language') + def when(self, matches, context): to_rename = [] to_remove = matches.named('subtitle_language.prefix') @@ -184,6 +385,7 @@ class SubtitlePrefixLanguageRule(Rule): lambda match: match.name == 'subtitle_language.prefix', 0) if prefix: to_rename.append((prefix, lang)) + to_remove.extend(matches.conflicting(lang)) if prefix in to_remove: to_remove.remove(prefix) return to_rename, to_remove @@ -211,6 +413,9 @@ class SubtitleSuffixLanguageRule(Rule): properties = {'subtitle_language': [None]} + def enabled(self, context): + return not is_disabled(context, 'subtitle_language') + def when(self, matches, context): to_append = [] to_remove = matches.named('subtitle_language.suffix') @@ -233,17 +438,66 @@ class SubtitleSuffixLanguageRule(Rule): class SubtitleExtensionRule(Rule): """ - Convert language guess as subtitle_language if next match is a subtitle extension + Convert language guess as subtitle_language if next match is a subtitle extension. + + Since it's a strong match, it also removes any conflicting source with it. """ - consequence = RenameMatch('subtitle_language') + consequence = [RemoveMatch, RenameMatch('subtitle_language')] properties = {'subtitle_language': [None]} - def when(self, matches, context): + def enabled(self, context): + return not is_disabled(context, 'subtitle_language') + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements subtitle_extension = matches.named('container', lambda match: 'extension' in match.tags and 'subtitle' in match.tags, 0) if subtitle_extension: subtitle_lang = matches.previous(subtitle_extension, lambda match: match.name == 'language', 0) if subtitle_lang: - return subtitle_lang + for weak in matches.named('subtitle_language', predicate=lambda m: 'weak-language' in m.tags): + weak.private = True + + return matches.conflicting(subtitle_lang, lambda m: m.name == 'source'), subtitle_lang + + +class RemoveLanguage(Rule): + """Remove language matches that were not converted to subtitle_language when language is disabled.""" + + consequence = RemoveMatch + + def enabled(self, context): + return is_disabled(context, 'language') + + def when(self, matches, context): + return matches.named('language') + + +class RemoveInvalidLanguages(Rule): + """Remove language matches that matches the blacklisted common words.""" + + consequence = RemoveMatch + + def __init__(self, common_words): + """Constructor.""" + super(RemoveInvalidLanguages, self).__init__() + self.common_words = common_words + + def when(self, matches, context): + to_remove = [] + for match in matches.range(0, len(matches.input_string), + predicate=lambda m: m.name in ('language', 'subtitle_language')): + if match.raw.lower() not in self.common_words: + continue + + group = matches.markers.at_match(match, index=0, predicate=lambda m: m.name == 'group') + if group and ( + not matches.range( + group.start, group.end, predicate=lambda m: m.name not in ('language', 'subtitle_language') + ) and (not matches.holes(group.start, group.end, predicate=lambda m: m.value.strip(seps)))): + continue + + to_remove.append(match) + + return to_remove diff --git a/libs/guessit/rules/properties/mimetype.py b/libs/guessit/rules/properties/mimetype.py index c57ada77..f9e642ff 100644 --- a/libs/guessit/rules/properties/mimetype.py +++ b/libs/guessit/rules/properties/mimetype.py @@ -8,16 +8,23 @@ import mimetypes from rebulk import Rebulk, CustomRule, POST_PROCESS from rebulk.match import Match +from ..common.pattern import is_disabled from ...rules.processors import Processors -def mimetype(): +def mimetype(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - return Rebulk().rules(Mimetype) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'mimetype')) + rebulk.rules(Mimetype) + + return rebulk class Mimetype(CustomRule): diff --git a/libs/guessit/rules/properties/other.py b/libs/guessit/rules/properties/other.py index 1c51eea7..330caa92 100644 --- a/libs/guessit/rules/properties/other.py +++ b/libs/guessit/rules/properties/other.py @@ -5,35 +5,50 @@ other property """ import copy +from rebulk import Rebulk, Rule, RemoveMatch, RenameMatch, POST_PROCESS, AppendMatch from rebulk.remodule import re -from rebulk import Rebulk, Rule, RemoveMatch, POST_PROCESS, AppendMatch from ..common import dash from ..common import seps -from ..common.validators import seps_surround, compose -from ...rules.common.formatters import raw_cleanup +from ..common.pattern import is_disabled +from ..common.validators import seps_after, seps_before, seps_surround, compose from ...reutils import build_or_pattern +from ...rules.common.formatters import raw_cleanup -def other(): +def other(config): # pylint:disable=unused-argument,too-many-statements """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'other')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) rebulk.defaults(name="other", validator=seps_surround) - rebulk.regex('Audio-?Fix', 'Audio-?Fixed', value='AudioFix') - rebulk.regex('Sync-?Fix', 'Sync-?Fixed', value='SyncFix') - rebulk.regex('Dual-?Audio', value='DualAudio') - rebulk.regex('ws', 'wide-?screen', value='WideScreen') - rebulk.string('Netflix', 'NF', value='Netflix') + rebulk.regex('Audio-?Fix', 'Audio-?Fixed', value='Audio Fixed') + rebulk.regex('Sync-?Fix', 'Sync-?Fixed', value='Sync Fixed') + rebulk.regex('Dual', 'Dual-?Audio', value='Dual Audio') + rebulk.regex('ws', 'wide-?screen', value='Widescreen') + rebulk.regex('Re-?Enc(?:oded)?', value='Reencoded') - rebulk.string('Real', 'Fix', 'Fixed', value='Proper', tags=['has-neighbor-before', 'has-neighbor-after']) - rebulk.string('Proper', 'Repack', 'Rerip', value='Proper') - rebulk.string('Fansub', value='Fansub', tags='has-neighbor') - rebulk.string('Fastsub', value='Fastsub', tags='has-neighbor') + rebulk.string('Proper', 'Repack', 'Rerip', value='Proper', + tags=['streaming_service.prefix', 'streaming_service.suffix']) + + rebulk.regex('Real-Proper', 'Real-Repack', 'Real-Rerip', value='Proper', + tags=['streaming_service.prefix', 'streaming_service.suffix', 'real']) + rebulk.string('Fix', 'Fixed', value='Fix', tags=['has-neighbor-before', 'has-neighbor-after', + 'streaming_service.prefix', 'streaming_service.suffix']) + rebulk.string('Dirfix', 'Nfofix', 'Prooffix', value='Fix', + tags=['streaming_service.prefix', 'streaming_service.suffix']) + rebulk.regex('(?:Proof-?)?Sample-?Fix', value='Fix', + tags=['streaming_service.prefix', 'streaming_service.suffix']) + + rebulk.string('Fansub', value='Fan Subtitled', tags='has-neighbor') + rebulk.string('Fastsub', value='Fast Subtitled', tags='has-neighbor') season_words = build_or_pattern(["seasons?", "series?"]) complete_articles = build_or_pattern(["The"]) @@ -58,24 +73,68 @@ def other(): value={'other': 'Complete'}, tags=['release-group-prefix'], validator={'__parent__': compose(seps_surround, validate_complete)}) - rebulk.string('R5', 'RC', value='R5') + rebulk.string('R5', value='Region 5') + rebulk.string('RC', value='Region C') rebulk.regex('Pre-?Air', value='Preair') + rebulk.regex('(?:PS-?)?Vita', value='PS Vita') + rebulk.regex('(HD)(?PRip)', value={'other': 'HD', 'another': 'Rip'}, + private_parent=True, children=True, validator={'__parent__': seps_surround}, validate_all=True) - for value in ( - 'Screener', 'Remux', 'Remastered', '3D', 'HD', 'mHD', 'HDLight', 'HQ', 'DDC', 'HR', 'PAL', 'SECAM', 'NTSC', - 'CC', 'LD', 'MD', 'XXX'): + for value in ('Screener', 'Remux', '3D', 'PAL', 'SECAM', 'NTSC', 'XXX'): rebulk.string(value, value=value) - for value in ('Limited', 'Complete', 'Classic', 'Unrated', 'LiNE', 'Bonus', 'Trailer', 'FINAL', 'Retail', 'Uncut', - 'Extended', 'Extended Cut'): + rebulk.string('HQ', value='High Quality', tags='uhdbluray-neighbor') + rebulk.string('HR', value='High Resolution') + rebulk.string('LD', value='Line Dubbed') + rebulk.string('MD', value='Mic Dubbed') + rebulk.string('mHD', 'HDLight', value='Micro HD') + rebulk.string('LDTV', value='Low Definition') + rebulk.string('HFR', value='High Frame Rate') + rebulk.string('HD', value='HD', validator=None, + tags=['streaming_service.prefix', 'streaming_service.suffix']) + rebulk.regex('Full-?HD', 'FHD', value='Full HD', validator=None, + tags=['streaming_service.prefix', 'streaming_service.suffix']) + rebulk.regex('Ultra-?(?:HD)?', 'UHD', value='Ultra HD', validator=None, + tags=['streaming_service.prefix', 'streaming_service.suffix']) + rebulk.regex('Upscaled?', value='Upscaled') + + for value in ('Complete', 'Classic', 'Bonus', 'Trailer', 'Retail', + 'Colorized', 'Internal'): rebulk.string(value, value=value, tags=['has-neighbor', 'release-group-prefix']) + rebulk.regex('LiNE', value='Line Audio', tags=['has-neighbor-before', 'has-neighbor-after', 'release-group-prefix']) + rebulk.regex('Read-?NFO', value='Read NFO') + rebulk.string('CONVERT', value='Converted', tags='has-neighbor') + rebulk.string('DOCU', 'DOKU', value='Documentary', tags='has-neighbor') + rebulk.string('OM', value='Open Matte', tags='has-neighbor') + rebulk.string('STV', value='Straight to Video', tags='has-neighbor') + rebulk.string('OAR', value='Original Aspect Ratio', tags='has-neighbor') + rebulk.string('Complet', value='Complete', tags=['has-neighbor', 'release-group-prefix']) - rebulk.string('VO', 'OV', value='OV', tags='has-neighbor') + for coast in ('East', 'West'): + rebulk.regex(r'(?:Live-)?(?:Episode-)?' + coast + '-?(?:Coast-)?Feed', value=coast + ' Coast Feed') - rebulk.regex('Scr(?:eener)?', value='Screener', validator=None, tags='other.validate.screener') + rebulk.string('VO', 'OV', value='Original Video', tags='has-neighbor') + rebulk.string('Ova', 'Oav', value='Original Animated Video') - rebulk.rules(ValidateHasNeighbor, ValidateHasNeighborAfter, ValidateHasNeighborBefore, ValidateScreenerRule, - ProperCountRule) + rebulk.regex('Scr(?:eener)?', value='Screener', validator=None, + tags=['other.validate.screener', 'source-prefix', 'source-suffix']) + rebulk.string('Mux', value='Mux', validator=seps_after, + tags=['other.validate.mux', 'video-codec-prefix', 'source-suffix']) + rebulk.string('HC', 'vost', value='Hardcoded Subtitles') + + rebulk.string('SDR', value='Standard Dynamic Range', tags='uhdbluray-neighbor') + rebulk.regex('HDR(?:10)?', value='HDR10', tags='uhdbluray-neighbor') + rebulk.regex('Dolby-?Vision', value='Dolby Vision', tags='uhdbluray-neighbor') + rebulk.regex('BT-?2020', value='BT.2020', tags='uhdbluray-neighbor') + + rebulk.string('Sample', value='Sample', tags=['at-end', 'not-a-release-group']) + rebulk.string('Proof', value='Proof', tags=['at-end', 'not-a-release-group']) + rebulk.string('Obfuscated', 'Scrambled', value='Obfuscated', tags=['at-end', 'not-a-release-group']) + rebulk.string('xpost', 'postbot', 'asrequested', value='Repost', tags='not-a-release-group') + + rebulk.rules(RenameAnotherToOther, ValidateHasNeighbor, ValidateHasNeighborAfter, ValidateHasNeighborBefore, + ValidateScreenerRule, ValidateMuxRule, ValidateHardcodedSubs, ValidateStreamingServiceNeighbor, + ValidateAtEnd, ProperCountRule) return rebulk @@ -90,7 +149,7 @@ class ProperCountRule(Rule): properties = {'proper_count': [None]} - def when(self, matches, context): + def when(self, matches, context): # pylint:disable=inconsistent-return-statements propers = matches.named('other', lambda match: match.value == 'Proper') if propers: raws = {} # Count distinct raw values @@ -98,15 +157,32 @@ class ProperCountRule(Rule): raws[raw_cleanup(proper.raw)] = proper proper_count_match = copy.copy(propers[-1]) proper_count_match.name = 'proper_count' - proper_count_match.value = len(raws) + + value = 0 + for raw in raws.values(): + value += 2 if 'real' in raw.tags else 1 + + proper_count_match.value = value return proper_count_match +class RenameAnotherToOther(Rule): + """ + Rename `another` properties to `other` + """ + priority = 32 + consequence = RenameMatch('other') + + def when(self, matches, context): + return matches.named('another') + + class ValidateHasNeighbor(Rule): """ Validate tag has-neighbor """ consequence = RemoveMatch + priority = 64 def when(self, matches, context): ret = [] @@ -132,6 +208,7 @@ class ValidateHasNeighborBefore(Rule): Validate tag has-neighbor-before that previous match exists. """ consequence = RemoveMatch + priority = 64 def when(self, matches, context): ret = [] @@ -151,6 +228,7 @@ class ValidateHasNeighborAfter(Rule): Validate tag has-neighbor-after that next match exists. """ consequence = RemoveMatch + priority = 64 def when(self, matches, context): ret = [] @@ -175,7 +253,104 @@ class ValidateScreenerRule(Rule): def when(self, matches, context): ret = [] for screener in matches.named('other', lambda match: 'other.validate.screener' in match.tags): - format_match = matches.previous(screener, lambda match: match.name == 'format', 0) - if not format_match or matches.input_string[format_match.end:screener.start].strip(seps): + source_match = matches.previous(screener, lambda match: match.initiator.name == 'source', 0) + if not source_match or matches.input_string[source_match.end:screener.start].strip(seps): ret.append(screener) return ret + + +class ValidateMuxRule(Rule): + """ + Validate tag other.validate.mux + """ + consequence = RemoveMatch + priority = 64 + + def when(self, matches, context): + ret = [] + for mux in matches.named('other', lambda match: 'other.validate.mux' in match.tags): + source_match = matches.previous(mux, lambda match: match.initiator.name == 'source', 0) + if not source_match: + ret.append(mux) + return ret + + +class ValidateHardcodedSubs(Rule): + """Validate HC matches.""" + + priority = 32 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for hc_match in matches.named('other', predicate=lambda match: match.value == 'Hardcoded Subtitles'): + next_match = matches.next(hc_match, predicate=lambda match: match.name == 'subtitle_language', index=0) + if next_match and not matches.holes(hc_match.end, next_match.start, + predicate=lambda match: match.value.strip(seps)): + continue + + previous_match = matches.previous(hc_match, + predicate=lambda match: match.name == 'subtitle_language', index=0) + if previous_match and not matches.holes(previous_match.end, hc_match.start, + predicate=lambda match: match.value.strip(seps)): + continue + + to_remove.append(hc_match) + + return to_remove + + +class ValidateStreamingServiceNeighbor(Rule): + """Validate streaming service's neighbors.""" + + priority = 32 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for match in matches.named('other', + predicate=lambda m: (m.initiator.name != 'source' + and ('streaming_service.prefix' in m.tags + or 'streaming_service.suffix' in m.tags))): + match = match.initiator + if not seps_after(match): + if 'streaming_service.prefix' in match.tags: + next_match = matches.next(match, lambda m: m.name == 'streaming_service', 0) + if next_match and not matches.holes(match.end, next_match.start, + predicate=lambda m: m.value.strip(seps)): + continue + if match.children: + to_remove.extend(match.children) + to_remove.append(match) + + elif not seps_before(match): + if 'streaming_service.suffix' in match.tags: + previous_match = matches.previous(match, lambda m: m.name == 'streaming_service', 0) + if previous_match and not matches.holes(previous_match.end, match.start, + predicate=lambda m: m.value.strip(seps)): + continue + + if match.children: + to_remove.extend(match.children) + to_remove.append(match) + + return to_remove + + +class ValidateAtEnd(Rule): + """Validate other which should occur at the end of a filepart.""" + + priority = 32 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + for match in matches.range(filepart.start, filepart.end, + predicate=lambda m: m.name == 'other' and 'at-end' in m.tags): + if (matches.holes(match.end, filepart.end, predicate=lambda m: m.value.strip(seps)) or + matches.range(match.end, filepart.end, predicate=lambda m: m.name not in ( + 'other', 'container'))): + to_remove.append(match) + + return to_remove diff --git a/libs/guessit/rules/properties/part.py b/libs/guessit/rules/properties/part.py index d274f7fb..ec038b18 100644 --- a/libs/guessit/rules/properties/part.py +++ b/libs/guessit/rules/properties/part.py @@ -7,20 +7,25 @@ from rebulk.remodule import re from rebulk import Rebulk from ..common import dash +from ..common.pattern import is_disabled from ..common.validators import seps_surround, int_coercable, compose from ..common.numeral import numeral, parse_numeral from ...reutils import build_or_pattern -def part(): +def part(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash], validator={'__parent__': seps_surround}) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'part')) + rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash], validator={'__parent__': seps_surround}) - prefixes = ['pt', 'part'] + prefixes = config['prefixes'] def validate_roman(match): """ diff --git a/libs/guessit/rules/properties/release_group.py b/libs/guessit/rules/properties/release_group.py index b92ad168..ff1ac660 100644 --- a/libs/guessit/rules/properties/release_group.py +++ b/libs/guessit/rules/properties/release_group.py @@ -5,84 +5,195 @@ release_group property """ import copy -from rebulk.remodule import re +from rebulk import Rebulk, Rule, AppendMatch, RemoveMatch +from rebulk.match import Match -from rebulk import Rebulk, Rule, AppendMatch -from ..common.validators import int_coercable -from ..properties.title import TitleFromPosition -from ..common.formatters import cleanup -from ..common import seps, dash +from ..common import seps +from ..common.expected import build_expected_function from ..common.comparators import marker_sorted +from ..common.formatters import cleanup +from ..common.pattern import is_disabled +from ..common.validators import int_coercable, seps_surround +from ..properties.title import TitleFromPosition -def release_group(): +def release_group(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - return Rebulk().rules(SceneReleaseGroup, AnimeReleaseGroup, ExpectedReleaseGroup) + forbidden_groupnames = config['forbidden_names'] + + groupname_ignore_seps = config['ignored_seps'] + groupname_seps = ''.join([c for c in seps if c not in groupname_ignore_seps]) + + def clean_groupname(string): + """ + Removes and strip separators from input_string + :param string: + :type string: + :return: + :rtype: + """ + string = string.strip(groupname_seps) + if not (string.endswith(tuple(groupname_ignore_seps)) and string.startswith(tuple(groupname_ignore_seps))) \ + and not any(i in string.strip(groupname_ignore_seps) for i in groupname_ignore_seps): + string = string.strip(groupname_ignore_seps) + for forbidden in forbidden_groupnames: + if string.lower().startswith(forbidden) and string[len(forbidden):len(forbidden) + 1] in seps: + string = string[len(forbidden):] + string = string.strip(groupname_seps) + if string.lower().endswith(forbidden) and string[-len(forbidden) - 1:-len(forbidden)] in seps: + string = string[:len(forbidden)] + string = string.strip(groupname_seps) + return string + + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'release_group')) + + expected_group = build_expected_function('expected_group') + + rebulk.functional(expected_group, name='release_group', tags=['expected'], + validator=seps_surround, + conflict_solver=lambda match, other: other, + disabled=lambda context: not context.get('expected_group')) + + return rebulk.rules( + DashSeparatedReleaseGroup(clean_groupname), + SceneReleaseGroup(clean_groupname), + AnimeReleaseGroup + ) -forbidden_groupnames = ['rip', 'by', 'for', 'par', 'pour', 'bonus'] - -groupname_ignore_seps = '[]{}()' -groupname_seps = ''.join([c for c in seps if c not in groupname_ignore_seps]) - - -def clean_groupname(string): - """ - Removes and strip separators from input_string - :param input_string: - :type input_string: - :return: - :rtype: - """ - string = string.strip(groupname_seps) - if not (string.endswith(tuple(groupname_ignore_seps)) and string.startswith(tuple(groupname_ignore_seps)))\ - and not any(i in string.strip(groupname_ignore_seps) for i in groupname_ignore_seps): - string = string.strip(groupname_ignore_seps) - for forbidden in forbidden_groupnames: - if string.lower().startswith(forbidden): - string = string[len(forbidden):] - string = string.strip(groupname_seps) - if string.lower().endswith(forbidden): - string = string[:len(forbidden)] - string = string.strip(groupname_seps) - return string - - -_scene_previous_names = ['video_codec', 'format', 'video_api', 'audio_codec', 'audio_profile', 'video_profile', +_scene_previous_names = ('video_codec', 'source', 'video_api', 'audio_codec', 'audio_profile', 'video_profile', 'audio_channels', 'screen_size', 'other', 'container', 'language', 'subtitle_language', - 'subtitle_language.suffix', 'subtitle_language.prefix'] + 'subtitle_language.suffix', 'subtitle_language.prefix', 'language.suffix') -_scene_previous_tags = ['release-group-prefix'] +_scene_previous_tags = ('release-group-prefix', ) -class ExpectedReleaseGroup(Rule): +class DashSeparatedReleaseGroup(Rule): """ - Add release_group match from expected_group option + Detect dash separated release groups that might appear at the end or at the beginning of a release name. + + Series.S01E02.Pilot.DVDRip.x264-CS.mkv + release_group: CS + abc-the.title.name.1983.1080p.bluray.x264.mkv + release_group: abc + + At the end: Release groups should be dash-separated and shouldn't contain spaces nor + appear in a group with other matches. The preceding matches should be separated by dot. + If a release group is found, the conflicting matches are removed. + + At the beginning: Release groups should be dash-separated and shouldn't contain spaces nor appear in a group. + It should be followed by a hole with dot-separated words. + Detection only happens if no matches exist at the beginning. """ - consequence = AppendMatch + consequence = [RemoveMatch, AppendMatch] - properties = {'release_group': [None]} + def __init__(self, value_formatter): + """Default constructor.""" + super(DashSeparatedReleaseGroup, self).__init__() + self.value_formatter = value_formatter - def enabled(self, context): - return context.get('expected_group') + @classmethod + def is_valid(cls, matches, candidate, start, end, at_end): # pylint:disable=inconsistent-return-statements + """ + Whether a candidate is a valid release group. + """ + if not at_end: + if len(candidate.value) <= 1: + return False - def when(self, matches, context): - expected_rebulk = Rebulk().defaults(name='release_group') + if matches.markers.at_match(candidate, predicate=lambda m: m.name == 'group'): + return False - for expected_group in context.get('expected_group'): - if expected_group.startswith('re:'): - expected_group = expected_group[3:] - expected_group = expected_group.replace(' ', '-') - expected_rebulk.regex(expected_group, abbreviations=[dash], flags=re.IGNORECASE) + first_hole = matches.holes(candidate.end, end, predicate=lambda m: m.start == candidate.end, index=0) + if not first_hole: + return False + + raw_value = first_hole.raw + return raw_value[0] == '-' and '-' not in raw_value[1:] and '.' in raw_value and ' ' not in raw_value + + group = matches.markers.at_match(candidate, predicate=lambda m: m.name == 'group', index=0) + if group and matches.at_match(group, predicate=lambda m: not m.private and m.span != candidate.span): + return False + + count = 0 + match = candidate + while match: + current = matches.range(start, + match.start, + index=-1, + predicate=lambda m: not m.private and not 'expected' in m.tags) + if not current: + break + + separator = match.input_string[current.end:match.start] + if not separator and match.raw[0] == '-': + separator = '-' + + match = current + + if count == 0: + if separator != '-': + break + + count += 1 + continue + + if separator == '.': + return True + + def detect(self, matches, start, end, at_end): # pylint:disable=inconsistent-return-statements + """ + Detect release group at the end or at the beginning of a filepart. + """ + candidate = None + if at_end: + container = matches.ending(end, lambda m: m.name == 'container', index=0) + if container: + end = container.start + + candidate = matches.ending(end, index=0, predicate=( + lambda m: not m.private and not ( + m.name == 'other' and 'not-a-release-group' in m.tags + ) and '-' not in m.raw and m.raw.strip() == m.raw)) + + if not candidate: + if at_end: + candidate = matches.holes(start, end, seps=seps, index=-1, + predicate=lambda m: m.end == end and m.raw.strip(seps) and m.raw[0] == '-') else: - expected_rebulk.string(expected_group, ignore_case=True) + candidate = matches.holes(start, end, seps=seps, index=0, + predicate=lambda m: m.start == start and m.raw.strip(seps)) - matches = expected_rebulk.matches(matches.input_string, context) - return matches + if candidate and self.is_valid(matches, candidate, start, end, at_end): + return candidate + + def when(self, matches, context): # pylint:disable=inconsistent-return-statements + if matches.named('release_group'): + return + + to_remove = [] + to_append = [] + for filepart in matches.markers.named('path'): + candidate = self.detect(matches, filepart.start, filepart.end, True) + if candidate: + to_remove.extend(matches.at_match(candidate)) + else: + candidate = self.detect(matches, filepart.start, filepart.end, False) + + if candidate: + releasegroup = Match(candidate.start, candidate.end, name='release_group', + formatter=self.value_formatter, input_string=candidate.input_string) + + if releasegroup.value: + to_append.append(releasegroup) + return to_remove, to_append class SceneReleaseGroup(Rule): @@ -91,26 +202,61 @@ class SceneReleaseGroup(Rule): Something.XViD-ReleaseGroup.mkv """ - dependency = [TitleFromPosition, ExpectedReleaseGroup] + dependency = [TitleFromPosition] consequence = AppendMatch properties = {'release_group': [None]} - def when(self, matches, context): + def __init__(self, value_formatter): + """Default constructor.""" + super(SceneReleaseGroup, self).__init__() + self.value_formatter = value_formatter + + def when(self, matches, context): # pylint:disable=too-many-locals # If a release_group is found before, ignore this kind of release_group rule. ret = [] for filepart in marker_sorted(matches.markers.named('path'), matches): + # pylint:disable=cell-var-from-loop start, end = filepart.span + if matches.named('release_group', predicate=lambda m: m.start >= start and m.end <= end): + continue - last_hole = matches.holes(start, end + 1, formatter=clean_groupname, + titles = matches.named('title', predicate=lambda m: m.start >= start and m.end <= end) + + def keep_only_first_title(match): + """ + Keep only first title from this filepart, as other ones are most likely release group. + + :param match: + :type match: + :return: + :rtype: + """ + return match in titles[1:] + + last_hole = matches.holes(start, end + 1, formatter=self.value_formatter, + ignore=keep_only_first_title, predicate=lambda hole: cleanup(hole.value), index=-1) if last_hole: + def previous_match_filter(match): + """ + Filter to apply to find previous match + + :param match: + :type match: + :return: + :rtype: + """ + + if match.start < filepart.start: + return False + return not match.private or match.name in _scene_previous_names + previous_match = matches.previous(last_hole, - lambda match: not match.private or - match.name in _scene_previous_names, + previous_match_filter, index=0) if previous_match and (previous_match.name in _scene_previous_names or any(tag in previous_match.tags for tag in _scene_previous_tags)) and \ @@ -123,12 +269,17 @@ class SceneReleaseGroup(Rule): # if hole is inside a group marker with same value, remove [](){} ... group = matches.markers.at_match(last_hole, lambda marker: marker.name == 'group', 0) if group: - group.formatter = clean_groupname + group.formatter = self.value_formatter if group.value == last_hole.value: last_hole.start = group.start + 1 last_hole.end = group.end - 1 last_hole.tags = ['anime'] + ignored_matches = matches.range(last_hole.start, last_hole.end, keep_only_first_title) + + for ignored_match in ignored_matches: + matches.remove(ignored_match) + ret.append(last_hole) return ret @@ -139,33 +290,42 @@ class AnimeReleaseGroup(Rule): ...[ReleaseGroup] Something.mkv """ dependency = [SceneReleaseGroup, TitleFromPosition] - consequence = AppendMatch + consequence = [RemoveMatch, AppendMatch] properties = {'release_group': [None]} def when(self, matches, context): - ret = [] + to_remove = [] + to_append = [] # If a release_group is found before, ignore this kind of release_group rule. + if matches.named('release_group'): + return to_remove, to_append + if not matches.named('episode') and not matches.named('season') and matches.named('release_group'): - # This doesn't seems to be an anime - return + # This doesn't seems to be an anime, and we already found another release_group. + return to_remove, to_append for filepart in marker_sorted(matches.markers.named('path'), matches): # pylint:disable=bad-continuation - empty_group_marker = matches.markers \ - .range(filepart.start, filepart.end, lambda marker: marker.name == 'group' - and not matches.range(marker.start, marker.end) - and not int_coercable(marker.value.strip(seps)), - 0) + empty_group = matches.markers.range(filepart.start, + filepart.end, + lambda marker: (marker.name == 'group' + and not matches.range(marker.start, marker.end, + lambda m: + 'weak-language' not in m.tags) + and marker.value.strip(seps) + and not int_coercable(marker.value.strip(seps))), 0) - if empty_group_marker: - group = copy.copy(empty_group_marker) + if empty_group: + group = copy.copy(empty_group) group.marker = False group.raw_start += 1 group.raw_end -= 1 group.tags = ['anime'] group.name = 'release_group' - ret.append(group) - return ret + to_append.append(group) + to_remove.extend(matches.range(empty_group.start, empty_group.end, + lambda m: 'weak-language' in m.tags)) + return to_remove, to_append diff --git a/libs/guessit/rules/properties/screen_size.py b/libs/guessit/rules/properties/screen_size.py index 80d68c29..83a797c1 100644 --- a/libs/guessit/rules/properties/screen_size.py +++ b/libs/guessit/rules/properties/screen_size.py @@ -3,66 +3,115 @@ """ screen_size property """ +from rebulk.match import Match from rebulk.remodule import re -from rebulk import Rebulk, Rule, RemoveMatch +from rebulk import Rebulk, Rule, RemoveMatch, AppendMatch + +from ..common.pattern import is_disabled +from ..common.quantity import FrameRate from ..common.validators import seps_surround -from ..common import dash +from ..common import dash, seps +from ...reutils import build_or_pattern -def screen_size(): +def screen_size(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - def conflict_solver(match, other): - """ - Conflict solver for most screen_size. - """ - if other.name == 'screen_size': - if 'resolution' in other.tags: - # The chtouile to solve conflict in "720 x 432" string matching both 720p pattern - int_value = _digits_re.findall(match.raw)[-1] - if other.value.startswith(int_value): - return match - return other - return '__default__' + interlaced = frozenset({res for res in config['interlaced']}) + progressive = frozenset({res for res in config['progressive']}) + frame_rates = [re.escape(rate) for rate in config['frame_rates']] + min_ar = config['min_ar'] + max_ar = config['max_ar'] - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE) - rebulk.defaults(name="screen_size", validator=seps_surround, conflict_solver=conflict_solver) + rebulk = Rebulk() + rebulk = rebulk.string_defaults(ignore_case=True).regex_defaults(flags=re.IGNORECASE) - rebulk.regex(r"(?:\d{3,}(?:x|\*))?360(?:i|p?x?)", value="360p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?368(?:i|p?x?)", value="368p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?480(?:i|p?x?)", value="480p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?576(?:i|p?x?)", value="576p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?720(?:i|p?(?:50|60)?x?)", value="720p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?720(?:p(?:50|60)?x?)", value="720p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?720p?hd", value="720p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?900(?:i|p?x?)", value="900p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?1080i", value="1080i") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?1080p?x?", value="1080p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?1080(?:p(?:50|60)?x?)", value="1080p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?1080p?hd", value="1080p") - rebulk.regex(r"(?:\d{3,}(?:x|\*))?2160(?:i|p?x?)", value="4K") + rebulk.defaults(name='screen_size', validator=seps_surround, abbreviations=[dash], + disabled=lambda context: is_disabled(context, 'screen_size')) - _digits_re = re.compile(r'\d+') + frame_rate_pattern = build_or_pattern(frame_rates, name='frame_rate') + interlaced_pattern = build_or_pattern(interlaced, name='height') + progressive_pattern = build_or_pattern(progressive, name='height') - rebulk.defaults(name="screen_size", validator=seps_surround) - rebulk.regex(r'\d{3,}-?(?:x|\*)-?\d{3,}', - formatter=lambda value: 'x'.join(_digits_re.findall(value)), - abbreviations=[dash], - tags=['resolution'], + res_pattern = r'(?:(?P\d{3,4})(?:x|\*))?' + rebulk.regex(res_pattern + interlaced_pattern + r'(?Pi)' + frame_rate_pattern + '?') + rebulk.regex(res_pattern + progressive_pattern + r'(?Pp)' + frame_rate_pattern + '?') + rebulk.regex(res_pattern + progressive_pattern + r'(?Pp)?(?:hd)') + rebulk.regex(res_pattern + progressive_pattern + r'(?Pp)?x?') + rebulk.string('4k', value='2160p') + rebulk.regex(r'(?P\d{3,4})-?(?:x|\*)-?(?P\d{3,4})', conflict_solver=lambda match, other: '__default__' if other.name == 'screen_size' else other) - rebulk.rules(ScreenSizeOnlyOne) + rebulk.regex(frame_rate_pattern + '(p|fps)', name='frame_rate', + formatter=FrameRate.fromstring, disabled=lambda context: is_disabled(context, 'frame_rate')) + + rebulk.rules(PostProcessScreenSize(progressive, min_ar, max_ar), ScreenSizeOnlyOne, ResolveScreenSizeConflicts) return rebulk +class PostProcessScreenSize(Rule): + """ + Process the screen size calculating the aspect ratio if available. + + Convert to a standard notation (720p, 1080p, etc) when it's a standard resolution and + aspect ratio is valid or not available. + + It also creates an aspect_ratio match when available. + """ + consequence = AppendMatch + + def __init__(self, standard_heights, min_ar, max_ar): + super(PostProcessScreenSize, self).__init__() + self.standard_heights = standard_heights + self.min_ar = min_ar + self.max_ar = max_ar + + def when(self, matches, context): + to_append = [] + for match in matches.named('screen_size'): + if not is_disabled(context, 'frame_rate'): + for frame_rate in match.children.named('frame_rate'): + frame_rate.formatter = FrameRate.fromstring + to_append.append(frame_rate) + + values = match.children.to_dict() + if 'height' not in values: + continue + + scan_type = (values.get('scan_type') or 'p').lower() + height = values['height'] + if 'width' not in values: + match.value = '{0}{1}'.format(height, scan_type) + continue + + width = values['width'] + calculated_ar = float(width) / float(height) + + aspect_ratio = Match(match.start, match.end, input_string=match.input_string, + name='aspect_ratio', value=round(calculated_ar, 3)) + + if not is_disabled(context, 'aspect_ratio'): + to_append.append(aspect_ratio) + + if height in self.standard_heights and self.min_ar < calculated_ar < self.max_ar: + match.value = '{0}{1}'.format(height, scan_type) + else: + match.value = '{0}x{1}'.format(width, height) + + return to_append + + class ScreenSizeOnlyOne(Rule): """ - Keep a single screen_size pet filepath part. + Keep a single screen_size per filepath part. """ consequence = RemoveMatch @@ -71,7 +120,44 @@ class ScreenSizeOnlyOne(Rule): for filepart in matches.markers.named('path'): screensize = list(reversed(matches.range(filepart.start, filepart.end, lambda match: match.name == 'screen_size'))) - if len(screensize) > 1: + if len(screensize) > 1 and len(set((match.value for match in screensize))) > 1: to_remove.extend(screensize[1:]) return to_remove + + +class ResolveScreenSizeConflicts(Rule): + """ + Resolve screen_size conflicts with season and episode matches. + """ + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for filepart in matches.markers.named('path'): + screensize = matches.range(filepart.start, filepart.end, lambda match: match.name == 'screen_size', 0) + if not screensize: + continue + + conflicts = matches.conflicting(screensize, lambda match: match.name in ('season', 'episode')) + if not conflicts: + continue + + has_neighbor = False + video_profile = matches.range(screensize.end, filepart.end, lambda match: match.name == 'video_profile', 0) + if video_profile and not matches.holes(screensize.end, video_profile.start, + predicate=lambda h: h.value and h.value.strip(seps)): + to_remove.extend(conflicts) + has_neighbor = True + + previous = matches.previous(screensize, index=0, predicate=( + lambda m: m.name in ('date', 'source', 'other', 'streaming_service'))) + if previous and not matches.holes(previous.end, screensize.start, + predicate=lambda h: h.value and h.value.strip(seps)): + to_remove.extend(conflicts) + has_neighbor = True + + if not has_neighbor: + to_remove.append(screensize) + + return to_remove diff --git a/libs/guessit/rules/properties/size.py b/libs/guessit/rules/properties/size.py new file mode 100644 index 00000000..c61580c0 --- /dev/null +++ b/libs/guessit/rules/properties/size.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +size property +""" +import re + +from rebulk import Rebulk + +from ..common import dash +from ..common.quantity import Size +from ..common.pattern import is_disabled +from ..common.validators import seps_surround + + +def size(config): # pylint:disable=unused-argument + """ + Builder for rebulk object. + + :param config: rule configuration + :type config: dict + :return: Created Rebulk object + :rtype: Rebulk + """ + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'size')) + rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) + rebulk.defaults(name='size', validator=seps_surround) + rebulk.regex(r'\d+-?[mgt]b', r'\d+\.\d+-?[mgt]b', formatter=Size.fromstring, tags=['release-group-prefix']) + + return rebulk diff --git a/libs/guessit/rules/properties/source.py b/libs/guessit/rules/properties/source.py new file mode 100644 index 00000000..ae9a7b03 --- /dev/null +++ b/libs/guessit/rules/properties/source.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +source property +""" +import copy + +from rebulk.remodule import re + +from rebulk import AppendMatch, Rebulk, RemoveMatch, Rule + +from .audio_codec import HqConflictRule +from ..common import dash, seps +from ..common.pattern import is_disabled +from ..common.validators import seps_before, seps_after + + +def source(config): # pylint:disable=unused-argument + """ + Builder for rebulk object. + + :param config: rule configuration + :type config: dict + :return: Created Rebulk object + :rtype: Rebulk + """ + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'source')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash], private_parent=True, children=True) + rebulk.defaults(name='source', tags=['video-codec-prefix', 'streaming_service.suffix']) + + rip_prefix = '(?PRip)-?' + rip_suffix = '-?(?PRip)' + rip_optional_suffix = '(?:' + rip_suffix + ')?' + + def build_source_pattern(*patterns, **kwargs): + """Helper pattern to build source pattern.""" + prefix_format = kwargs.get('prefix') or '' + suffix_format = kwargs.get('suffix') or '' + + string_format = prefix_format + '({0})' + suffix_format + return [string_format.format(pattern) for pattern in patterns] + + def demote_other(match, other): # pylint: disable=unused-argument + """Default conflict solver with 'other' property.""" + return other if other.name == 'other' else '__default__' + + rebulk.regex(*build_source_pattern('VHS', suffix=rip_optional_suffix), + value={'source': 'VHS', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('CAM', suffix=rip_optional_suffix), + value={'source': 'Camera', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('HD-?CAM', suffix=rip_optional_suffix), + value={'source': 'HD Camera', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TELESYNC', 'TS', suffix=rip_optional_suffix), + value={'source': 'Telesync', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('HD-?TELESYNC', 'HD-?TS', suffix=rip_optional_suffix), + value={'source': 'HD Telesync', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('WORKPRINT', 'WP'), value='Workprint') + rebulk.regex(*build_source_pattern('TELECINE', 'TC', suffix=rip_optional_suffix), + value={'source': 'Telecine', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('HD-?TELECINE', 'HD-?TC', suffix=rip_optional_suffix), + value={'source': 'HD Telecine', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('PPV', suffix=rip_optional_suffix), + value={'source': 'Pay-per-view', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('SD-?TV', suffix=rip_optional_suffix), + value={'source': 'TV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TV', suffix=rip_suffix), # TV is too common to allow matching + value={'source': 'TV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TV', 'SD-?TV', prefix=rip_prefix), + value={'source': 'TV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TV-?(?=Dub)'), value='TV') + rebulk.regex(*build_source_pattern('DVB', 'PD-?TV', suffix=rip_optional_suffix), + value={'source': 'Digital TV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('DVD', suffix=rip_optional_suffix), + value={'source': 'DVD', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('DM', suffix=rip_optional_suffix), + value={'source': 'Digital Master', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('VIDEO-?TS', 'DVD-?R(?:$|(?!E))', # 'DVD-?R(?:$|^E)' => DVD-Real ... + 'DVD-?9', 'DVD-?5'), value='DVD') + + rebulk.regex(*build_source_pattern('HD-?TV', suffix=rip_optional_suffix), conflict_solver=demote_other, + value={'source': 'HDTV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TV-?HD', suffix=rip_suffix), conflict_solver=demote_other, + value={'source': 'HDTV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('TV', suffix='-?(?PRip-?HD)'), conflict_solver=demote_other, + value={'source': 'HDTV', 'other': 'Rip'}) + + rebulk.regex(*build_source_pattern('VOD', suffix=rip_optional_suffix), + value={'source': 'Video on Demand', 'other': 'Rip'}) + + rebulk.regex(*build_source_pattern('WEB', 'WEB-?DL', suffix=rip_suffix), + value={'source': 'Web', 'other': 'Rip'}) + # WEBCap is a synonym to WEBRip, mostly used by non english + rebulk.regex(*build_source_pattern('WEB-?(?PCap)', suffix=rip_optional_suffix), + value={'source': 'Web', 'other': 'Rip', 'another': 'Rip'}) + rebulk.regex(*build_source_pattern('WEB-?DL', 'WEB-?U?HD', 'WEB', 'DL-?WEB', 'DL(?=-?Mux)'), + value={'source': 'Web'}) + + rebulk.regex(*build_source_pattern('HD-?DVD', suffix=rip_optional_suffix), + value={'source': 'HD-DVD', 'other': 'Rip'}) + + rebulk.regex(*build_source_pattern('Blu-?ray', 'BD', 'BD[59]', 'BD25', 'BD50', suffix=rip_optional_suffix), + value={'source': 'Blu-ray', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('(?PBR)-?(?=Scr(?:eener)?)', '(?PBR)-?(?=Mux)'), # BRRip + value={'source': 'Blu-ray', 'another': 'Reencoded'}) + rebulk.regex(*build_source_pattern('(?PBR)', suffix=rip_suffix), # BRRip + value={'source': 'Blu-ray', 'other': 'Rip', 'another': 'Reencoded'}) + + rebulk.regex(*build_source_pattern('Ultra-?Blu-?ray', 'Blu-?ray-?Ultra'), value='Ultra HD Blu-ray') + + rebulk.regex(*build_source_pattern('AHDTV'), value='Analog HDTV') + rebulk.regex(*build_source_pattern('UHD-?TV', suffix=rip_optional_suffix), conflict_solver=demote_other, + value={'source': 'Ultra HDTV', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('UHD', suffix=rip_suffix), conflict_solver=demote_other, + value={'source': 'Ultra HDTV', 'other': 'Rip'}) + + rebulk.regex(*build_source_pattern('DSR', 'DTH', suffix=rip_optional_suffix), + value={'source': 'Satellite', 'other': 'Rip'}) + rebulk.regex(*build_source_pattern('DSR?', 'SAT', suffix=rip_suffix), + value={'source': 'Satellite', 'other': 'Rip'}) + + rebulk.rules(ValidateSource, UltraHdBlurayRule) + + return rebulk + + +class UltraHdBlurayRule(Rule): + """ + Replace other:Ultra HD and source:Blu-ray with source:Ultra HD Blu-ray + """ + dependency = HqConflictRule + consequence = [RemoveMatch, AppendMatch] + + @classmethod + def find_ultrahd(cls, matches, start, end, index): + """Find Ultra HD match.""" + return matches.range(start, end, index=index, predicate=( + lambda m: not m.private and m.name == 'other' and m.value == 'Ultra HD' + )) + + @classmethod + def validate_range(cls, matches, start, end): + """Validate no holes or invalid matches exist in the specified range.""" + return ( + not matches.holes(start, end, predicate=lambda m: m.value.strip(seps)) and + not matches.range(start, end, predicate=( + lambda m: not m.private and ( + m.name not in ('screen_size', 'color_depth') and ( + m.name != 'other' or 'uhdbluray-neighbor' not in m.tags)))) + ) + + def when(self, matches, context): + to_remove = [] + to_append = [] + for filepart in matches.markers.named('path'): + for match in matches.range(filepart.start, filepart.end, predicate=( + lambda m: not m.private and m.name == 'source' and m.value == 'Blu-ray')): + other = self.find_ultrahd(matches, filepart.start, match.start, -1) + if not other or not self.validate_range(matches, other.end, match.start): + other = self.find_ultrahd(matches, match.end, filepart.end, 0) + if not other or not self.validate_range(matches, match.end, other.start): + if not matches.range(filepart.start, filepart.end, predicate=( + lambda m: m.name == 'screen_size' and m.value == '2160p')): + continue + + if other: + other.private = True + + new_source = copy.copy(match) + new_source.value = 'Ultra HD Blu-ray' + to_remove.append(match) + to_append.append(new_source) + + return to_remove, to_append + + +class ValidateSource(Rule): + """ + Validate source with screener property, with video_codec property or separated + """ + priority = 64 + consequence = RemoveMatch + + def when(self, matches, context): + ret = [] + for match in matches.named('source'): + match = match.initiator + if not seps_before(match) and \ + not matches.range(match.start - 1, match.start - 2, + lambda m: 'source-prefix' in m.tags): + if match.children: + ret.extend(match.children) + ret.append(match) + continue + if not seps_after(match) and \ + not matches.range(match.end, match.end + 1, + lambda m: 'source-suffix' in m.tags): + if match.children: + ret.extend(match.children) + ret.append(match) + continue + return ret diff --git a/libs/guessit/rules/properties/streaming_service.py b/libs/guessit/rules/properties/streaming_service.py new file mode 100644 index 00000000..1302befb --- /dev/null +++ b/libs/guessit/rules/properties/streaming_service.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +streaming_service property +""" +import re + +from rebulk import Rebulk +from rebulk.rules import Rule, RemoveMatch + +from ..common.pattern import is_disabled +from ...rules.common import seps, dash +from ...rules.common.validators import seps_before, seps_after + + +def streaming_service(config): # pylint: disable=too-many-statements,unused-argument + """Streaming service property. + + :param config: rule configuration + :type config: dict + :return: + :rtype: Rebulk + """ + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'streaming_service')) + rebulk = rebulk.string_defaults(ignore_case=True).regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]) + rebulk.defaults(name='streaming_service', tags=['source-prefix']) + + rebulk.string('AE', 'A&E', value='A&E') + rebulk.string('AMBC', value='ABC') + rebulk.string('AUBC', value='ABC Australia') + rebulk.string('AJAZ', value='Al Jazeera English') + rebulk.string('AMC', value='AMC') + rebulk.string('AMZN', 'Amazon', value='Amazon Prime') + rebulk.regex('Amazon-?Prime', value='Amazon Prime') + rebulk.string('AS', value='Adult Swim') + rebulk.regex('Adult-?Swim', value='Adult Swim') + rebulk.string('ATK', value="America's Test Kitchen") + rebulk.string('ANPL', value='Animal Planet') + rebulk.string('ANLB', value='AnimeLab') + rebulk.string('AOL', value='AOL') + rebulk.string('ARD', value='ARD') + rebulk.string('iP', value='BBC iPlayer') + rebulk.regex('BBC-?iPlayer', value='BBC iPlayer') + rebulk.string('BRAV', value='BravoTV') + rebulk.string('CNLP', value='Canal+') + rebulk.string('CN', value='Cartoon Network') + rebulk.string('CBC', value='CBC') + rebulk.string('CBS', value='CBS') + rebulk.string('CNBC', value='CNBC') + rebulk.string('CC', value='Comedy Central') + rebulk.string('4OD', value='Channel 4') + rebulk.string('CHGD', value='CHRGD') + rebulk.string('CMAX', value='Cinemax') + rebulk.string('CMT', value='Country Music Television') + rebulk.regex('Comedy-?Central', value='Comedy Central') + rebulk.string('CCGC', value='Comedians in Cars Getting Coffee') + rebulk.string('CR', value='Crunchy Roll') + rebulk.string('CRKL', value='Crackle') + rebulk.regex('Crunchy-?Roll', value='Crunchy Roll') + rebulk.string('CSPN', value='CSpan') + rebulk.string('CTV', value='CTV') + rebulk.string('CUR', value='CuriosityStream') + rebulk.string('CWS', value='CWSeed') + rebulk.string('DSKI', value='Daisuki') + rebulk.string('DHF', value='Deadhouse Films') + rebulk.string('DDY', value='Digiturk Diledigin Yerde') + rebulk.string('DISC', 'Discovery', value='Discovery') + rebulk.string('DSNY', 'Disney', value='Disney') + rebulk.string('DIY', value='DIY Network') + rebulk.string('DOCC', value='Doc Club') + rebulk.string('DPLY', value='DPlay') + rebulk.string('ETV', value='E!') + rebulk.string('EPIX', value='ePix') + rebulk.string('ETTV', value='El Trece') + rebulk.string('ESPN', value='ESPN') + rebulk.string('ESQ', value='Esquire') + rebulk.string('FAM', value='Family') + rebulk.string('FJR', value='Family Jr') + rebulk.string('FOOD', value='Food Network') + rebulk.string('FOX', value='Fox') + rebulk.string('FREE', value='Freeform') + rebulk.string('FYI', value='FYI Network') + rebulk.string('GLBL', value='Global') + rebulk.string('GLOB', value='GloboSat Play') + rebulk.string('HLMK', value='Hallmark') + rebulk.string('HBO', value='HBO Go') + rebulk.regex('HBO-?Go', value='HBO Go') + rebulk.string('HGTV', value='HGTV') + rebulk.string('HIST', 'History', value='History') + rebulk.string('HULU', value='Hulu') + rebulk.string('ID', value='Investigation Discovery') + rebulk.string('IFC', value='IFC') + rebulk.string('iTunes', 'iT', value='iTunes') + rebulk.string('ITV', value='ITV') + rebulk.string('KNOW', value='Knowledge Network') + rebulk.string('LIFE', value='Lifetime') + rebulk.string('MTOD', value='Motor Trend OnDemand') + rebulk.string('MNBC', value='MSNBC') + rebulk.string('MTV', value='MTV') + rebulk.string('NATG', value='National Geographic') + rebulk.regex('National-?Geographic', value='National Geographic') + rebulk.string('NBA', value='NBA TV') + rebulk.regex('NBA-?TV', value='NBA TV') + rebulk.string('NBC', value='NBC') + rebulk.string('NF', 'Netflix', value='Netflix') + rebulk.string('NFL', value='NFL') + rebulk.string('NFLN', value='NFL Now') + rebulk.string('GC', value='NHL GameCenter') + rebulk.string('NICK', 'Nickelodeon', value='Nickelodeon') + rebulk.string('NRK', value='Norsk Rikskringkasting') + rebulk.string('PBS', value='PBS') + rebulk.string('PBSK', value='PBS Kids') + rebulk.string('PSN', value='Playstation Network') + rebulk.string('PLUZ', value='Pluzz') + rebulk.string('RTE', value='RTE One') + rebulk.string('SBS', value='SBS (AU)') + rebulk.string('SESO', 'SeeSo', value='SeeSo') + rebulk.string('SHMI', value='Shomi') + rebulk.string('SPIK', value='Spike') + rebulk.string('SPKE', value='Spike TV') + rebulk.regex('Spike-?TV', value='Spike TV') + rebulk.string('SNET', value='Sportsnet') + rebulk.string('SPRT', value='Sprout') + rebulk.string('STAN', value='Stan') + rebulk.string('STZ', value='Starz') + rebulk.string('SVT', value='Sveriges Television') + rebulk.string('SWER', value='SwearNet') + rebulk.string('SYFY', value='Syfy') + rebulk.string('TBS', value='TBS') + rebulk.string('TFOU', value='TFou') + rebulk.string('CW', value='The CW') + rebulk.regex('The-?CW', value='The CW') + rebulk.string('TLC', value='TLC') + rebulk.string('TUBI', value='TubiTV') + rebulk.string('TV3', value='TV3 Ireland') + rebulk.string('TV4', value='TV4 Sweeden') + rebulk.string('TVL', value='TV Land') + rebulk.regex('TV-?Land', value='TV Land') + rebulk.string('UFC', value='UFC') + rebulk.string('UKTV', value='UKTV') + rebulk.string('UNIV', value='Univision') + rebulk.string('USAN', value='USA Network') + rebulk.string('VLCT', value='Velocity') + rebulk.string('VH1', value='VH1') + rebulk.string('VICE', value='Viceland') + rebulk.string('VMEO', value='Vimeo') + rebulk.string('VRV', value='VRV') + rebulk.string('WNET', value='W Network') + rebulk.string('WME', value='WatchMe') + rebulk.string('WWEN', value='WWE Network') + rebulk.string('XBOX', value='Xbox Video') + rebulk.string('YHOO', value='Yahoo') + rebulk.string('RED', value='YouTube Red') + rebulk.string('ZDF', value='ZDF') + + rebulk.rules(ValidateStreamingService) + + return rebulk + + +class ValidateStreamingService(Rule): + """Validate streaming service matches.""" + + priority = 32 + consequence = RemoveMatch + + def when(self, matches, context): + """Streaming service is always before source. + + :param matches: + :type matches: rebulk.match.Matches + :param context: + :type context: dict + :return: + """ + to_remove = [] + for service in matches.named('streaming_service'): + next_match = matches.next(service, lambda match: 'streaming_service.suffix' in match.tags, 0) + previous_match = matches.previous(service, lambda match: 'streaming_service.prefix' in match.tags, 0) + has_other = service.initiator and service.initiator.children.named('other') + + if not has_other: + if (not next_match or + matches.holes(service.end, next_match.start, + predicate=lambda match: match.value.strip(seps)) or + not seps_before(service)): + if (not previous_match or + matches.holes(previous_match.end, service.start, + predicate=lambda match: match.value.strip(seps)) or + not seps_after(service)): + to_remove.append(service) + continue + + if service.value == 'Comedy Central': + # Current match is a valid streaming service, removing invalid Criterion Collection (CC) matches + to_remove.extend(matches.named('edition', predicate=lambda match: match.value == 'Criterion')) + + return to_remove diff --git a/libs/guessit/rules/properties/title.py b/libs/guessit/rules/properties/title.py index 067d432d..d1cafe2a 100644 --- a/libs/guessit/rules/properties/title.py +++ b/libs/guessit/rules/properties/title.py @@ -3,54 +3,37 @@ """ title property """ -import re from rebulk import Rebulk, Rule, AppendMatch, RemoveMatch, AppendTags from rebulk.formatters import formatters -from rebulk.pattern import RePattern -from rebulk.utils import find_all from .film import FilmTitleRule from .language import SubtitlePrefixLanguageRule, SubtitleSuffixLanguageRule, SubtitleExtensionRule -from ..common.formatters import cleanup, reorder_title +from ..common import seps, title_seps from ..common.comparators import marker_sorted -from ..common import seps, title_seps, dash +from ..common.expected import build_expected_function +from ..common.formatters import cleanup, reorder_title +from ..common.pattern import is_disabled +from ..common.validators import seps_surround -def title(): +def title(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().rules(TitleFromPosition, PreferTitleWithYear) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'title')) + rebulk.rules(TitleFromPosition, PreferTitleWithYear) - def expected_title(input_string, context): - """ - Expected title functional pattern. - :param input_string: - :type input_string: - :param context: - :type context: - :return: - :rtype: - """ - ret = [] - for search in context.get('expected_title'): - if search.startswith('re:'): - search = search[3:] - search = search.replace(' ', '-') - matches = RePattern(search, abbreviations=[dash], flags=re.IGNORECASE).matches(input_string, context) - for match in matches: - # Instance of 'list' has no 'span' member (no-member). Seems to be a pylint bug. - # pylint: disable=no-member - ret.append(match.span) - else: - for start in find_all(input_string, search, ignore_case=True): - ret.append((start, start+len(search))) - return ret + expected_title = build_expected_function('expected_title') - rebulk.functional(expected_title, name='title', tags=['expected'], + rebulk.functional(expected_title, name='title', tags=['expected', 'title'], + validator=seps_surround, + formatter=formatters(cleanup, reorder_title), conflict_solver=lambda match, other: other, disabled=lambda context: not context.get('expected_title')) @@ -116,7 +99,7 @@ class TitleBaseRule(Rule): Full word language and countries won't be ignored if they are uppercase. """ - return not (len(match) > 3 and match.raw.isupper()) and match.name in ['language', 'country', 'episode_details'] + return not (len(match) > 3 and match.raw.isupper()) and match.name in ('language', 'country', 'episode_details') def should_keep(self, match, to_keep, matches, filepart, hole, starting): """ @@ -136,7 +119,7 @@ class TitleBaseRule(Rule): :return: :rtype: """ - if match.name in ['language', 'country']: + if match.name in ('language', 'country'): # Keep language if exactly matching the hole. if len(hole.value) == len(match.raw): return True @@ -149,7 +132,7 @@ class TitleBaseRule(Rule): lambda c_match: c_match.name == match.name and c_match not in to_keep)) - if not other_languages: + if not other_languages and (not starting or len(match.raw) <= 3): return True return False @@ -164,10 +147,10 @@ class TitleBaseRule(Rule): :return: """ if context.get('type') == 'episode' and match.name == 'episode_details': - return False + return match.start >= hole.start and match.end <= hole.end return True - def check_titles_in_filepart(self, filepart, matches, context): + def check_titles_in_filepart(self, filepart, matches, context): # pylint:disable=inconsistent-return-statements """ Find title in filepart (ignoring language) """ @@ -176,12 +159,11 @@ class TitleBaseRule(Rule): holes = matches.holes(start, end + 1, formatter=formatters(cleanup, reorder_title), ignore=self.is_ignored, - predicate=lambda hole: hole.value) + predicate=lambda m: m.value) holes = self.holes_process(holes, matches) for hole in holes: - # pylint:disable=cell-var-from-loop if not hole or (self.hole_filter and not self.hole_filter(hole, matches)): continue @@ -192,8 +174,8 @@ class TitleBaseRule(Rule): if ignored_matches: for ignored_match in reversed(ignored_matches): - # pylint:disable=undefined-loop-variable - trailing = matches.chain_before(hole.end, seps, predicate=lambda match: match == ignored_match) + # pylint:disable=undefined-loop-variable, cell-var-from-loop + trailing = matches.chain_before(hole.end, seps, predicate=lambda m: m == ignored_match) if trailing: should_keep = self.should_keep(ignored_match, to_keep, matches, filepart, hole, False) if should_keep: @@ -210,7 +192,7 @@ class TitleBaseRule(Rule): for ignored_match in ignored_matches: if ignored_match not in to_keep: starting = matches.chain_after(hole.start, seps, - predicate=lambda match: match == ignored_match) + predicate=lambda m: m == ignored_match) if starting: should_keep = self.should_keep(ignored_match, to_keep, matches, filepart, hole, True) if should_keep: @@ -236,7 +218,7 @@ class TitleBaseRule(Rule): hole.tags = self.match_tags if self.alternative_match_name: # Split and keep values that can be a title - titles = hole.split(title_seps, lambda match: match.value) + titles = hole.split(title_seps, lambda m: m.value) for title_match in list(titles[1:]): previous_title = titles[titles.index(title_match) - 1] separator = matches.input_string[previous_title.end:title_match.start] @@ -253,14 +235,15 @@ class TitleBaseRule(Rule): return titles, to_remove def when(self, matches, context): + ret = [] + to_remove = [] + if matches.named(self.match_name, lambda match: 'expected' in match.tags): - return + return ret, to_remove fileparts = [filepart for filepart in list(marker_sorted(matches.markers.named('path'), matches)) if not self.filepart_filter or self.filepart_filter(filepart, matches)] - to_remove = [] - # Priorize fileparts containing the year years_fileparts = [] for filepart in fileparts: @@ -268,7 +251,6 @@ class TitleBaseRule(Rule): if year_match: years_fileparts.append(filepart) - ret = [] for filepart in fileparts: try: years_fileparts.remove(filepart) @@ -304,6 +286,9 @@ class TitleFromPosition(TitleBaseRule): def __init__(self): super(TitleFromPosition, self).__init__('title', ['title'], 'alternative_title') + def enabled(self, context): + return not is_disabled(context, 'alternative_title') + class PreferTitleWithYear(Rule): """ @@ -324,7 +309,7 @@ class PreferTitleWithYear(Rule): if filepart: year_match = matches.range(filepart.start, filepart.end, lambda match: match.name == 'year', 0) if year_match: - group = matches.markers.at_match(year_match, lambda group: group.name == 'group') + group = matches.markers.at_match(year_match, lambda m: m.name == 'group') if group: with_year_in_group.append(title_match) else: @@ -332,13 +317,13 @@ class PreferTitleWithYear(Rule): to_tag = [] if with_year_in_group: - title_values = set([title_match.value for title_match in with_year_in_group]) + title_values = {title_match.value for title_match in with_year_in_group} to_tag.extend(with_year_in_group) elif with_year: - title_values = set([title_match.value for title_match in with_year]) + title_values = {title_match.value for title_match in with_year} to_tag.extend(with_year) else: - title_values = set([title_match.value for title_match in titles]) + title_values = {title_match.value for title_match in titles} to_remove = [] for title_match in titles: diff --git a/libs/guessit/rules/properties/type.py b/libs/guessit/rules/properties/type.py index 6d798b64..6a2877ef 100644 --- a/libs/guessit/rules/properties/type.py +++ b/libs/guessit/rules/properties/type.py @@ -6,6 +6,7 @@ type property from rebulk import CustomRule, Rebulk, POST_PROCESS from rebulk.match import Match +from ..common.pattern import is_disabled from ...rules.processors import Processors @@ -19,13 +20,19 @@ def _type(matches, value): matches.append(Match(len(matches.input_string), len(matches.input_string), name='type', value=value)) -def type_(): +def type_(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - return Rebulk().rules(TypeProcessor) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'type')) + rebulk = rebulk.rules(TypeProcessor) + + return rebulk class TypeProcessor(CustomRule): @@ -45,9 +52,10 @@ class TypeProcessor(CustomRule): episode = matches.named('episode') season = matches.named('season') + absolute_episode = matches.named('absolute_episode') episode_details = matches.named('episode_details') - if episode or season or episode_details: + if episode or season or episode_details or absolute_episode: return 'episode' film = matches.named('film') diff --git a/libs/guessit/rules/properties/video_codec.py b/libs/guessit/rules/properties/video_codec.py index 2ab1cfaf..b08ddcae 100644 --- a/libs/guessit/rules/properties/video_codec.py +++ b/libs/guessit/rules/properties/video_codec.py @@ -7,42 +7,71 @@ from rebulk.remodule import re from rebulk import Rebulk, Rule, RemoveMatch -from guessit.rules.common.validators import seps_after, seps_before from ..common import dash -from ..common.validators import seps_surround +from ..common.pattern import is_disabled +from ..common.validators import seps_after, seps_before, seps_surround -def video_codec(): +def video_codec(config): # pylint:disable=unused-argument """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) - rebulk.defaults(name="video_codec") + rebulk = Rebulk() + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE, abbreviations=[dash]).string_defaults(ignore_case=True) + rebulk.defaults(name="video_codec", + tags=['source-suffix', 'streaming_service.suffix'], + disabled=lambda context: is_disabled(context, 'video_codec')) - rebulk.regex(r"Rv\d{2}", value="Real") - rebulk.regex("Mpeg2", value="Mpeg2") - rebulk.regex("DVDivX", "DivX", value="DivX") - rebulk.regex("XviD", value="XviD") - rebulk.regex("[hx]-?264(?:-?AVC(HD)?)?", "MPEG-?4(?:-?AVC(HD)?)", "AVCHD", value="h264") - rebulk.regex("[hx]-?265(?:-?HEVC)?", "HEVC", value="h265") + rebulk.regex(r'Rv\d{2}', value='RealVideo') + rebulk.regex('Mpe?g-?2', '[hx]-?262', value='MPEG-2') + rebulk.string("DVDivX", "DivX", value="DivX") + rebulk.string('XviD', value='Xvid') + rebulk.regex('VC-?1', value='VC-1') + rebulk.string('VP7', value='VP7') + rebulk.string('VP8', 'VP80', value='VP8') + rebulk.string('VP9', value='VP9') + rebulk.regex('[hx]-?263', value='H.263') + rebulk.regex('[hx]-?264', '(MPEG-?4)?AVC(?:HD)?', value='H.264') + rebulk.regex('[hx]-?265', 'HEVC', value='H.265') + rebulk.regex('(?Phevc)(?P10)', value={'video_codec': 'H.265', 'color_depth': '10-bit'}, + tags=['video-codec-suffix'], children=True) # http://blog.mediacoderhq.com/h264-profiles-and-levels/ - # http://fr.wikipedia.org/wiki/H.264 - rebulk.defaults(name="video_profile", validator=seps_surround) + # https://en.wikipedia.org/wiki/H.264/MPEG-4_AVC + rebulk.defaults(name="video_profile", + validator=seps_surround, + disabled=lambda context: is_disabled(context, 'video_profile')) - rebulk.regex('10.?bit', 'Hi10P', value='10bit') - rebulk.regex('8.?bit', value='8bit') + rebulk.string('BP', value='Baseline', tags='video_profile.rule') + rebulk.string('XP', 'EP', value='Extended', tags='video_profile.rule') + rebulk.string('MP', value='Main', tags='video_profile.rule') + rebulk.string('HP', 'HiP', value='High', tags='video_profile.rule') - rebulk.string('BP', value='BP', tags='video_profile.rule') - rebulk.string('XP', 'EP', value='XP', tags='video_profile.rule') - rebulk.string('MP', value='MP', tags='video_profile.rule') - rebulk.string('HP', 'HiP', value='HP', tags='video_profile.rule') - rebulk.regex('Hi422P', value='Hi422P', tags='video_profile.rule') - rebulk.regex('Hi444PP', value='Hi444PP', tags='video_profile.rule') + # https://en.wikipedia.org/wiki/Scalable_Video_Coding + rebulk.string('SC', 'SVC', value='Scalable Video Coding', tags='video_profile.rule') + # https://en.wikipedia.org/wiki/AVCHD + rebulk.regex('AVC(?:HD)?', value='Advanced Video Codec High Definition', tags='video_profile.rule') + # https://en.wikipedia.org/wiki/H.265/HEVC + rebulk.string('HEVC', value='High Efficiency Video Coding', tags='video_profile.rule') - rebulk.string('DXVA', value='DXVA', name='video_api') + rebulk.regex('Hi422P', value='High 4:2:2') + rebulk.regex('Hi444PP', value='High 4:4:4 Predictive') + rebulk.regex('Hi10P?', value='High 10') # no profile validation is required + + rebulk.string('DXVA', value='DXVA', name='video_api', + disabled=lambda context: is_disabled(context, 'video_api')) + + rebulk.defaults(name='color_depth', + validator=seps_surround, + disabled=lambda context: is_disabled(context, 'color_depth')) + rebulk.regex('12.?bits?', value='12-bit') + rebulk.regex('10.?bits?', 'YUV420P10', 'Hi10P?', value='10-bit') + rebulk.regex('8.?bits?', value='8-bit') rebulk.rules(ValidateVideoCodec, VideoProfileRule) @@ -51,19 +80,23 @@ def video_codec(): class ValidateVideoCodec(Rule): """ - Validate video_codec with format property or separated + Validate video_codec with source property or separated """ priority = 64 consequence = RemoveMatch + def enabled(self, context): + return not is_disabled(context, 'video_codec') + def when(self, matches, context): ret = [] for codec in matches.named('video_codec'): if not seps_before(codec) and \ - not matches.at_index(codec.start - 1, lambda match: match.name == 'format'): + not matches.at_index(codec.start - 1, lambda match: 'video-codec-prefix' in match.tags): ret.append(codec) continue - if not seps_after(codec): + if not seps_after(codec) and \ + not matches.at_index(codec.end + 1, lambda match: 'video-codec-suffix' in match.tags): ret.append(codec) continue return ret @@ -75,11 +108,16 @@ class VideoProfileRule(Rule): """ consequence = RemoveMatch + def enabled(self, context): + return not is_disabled(context, 'video_profile') + def when(self, matches, context): profile_list = matches.named('video_profile', lambda match: 'video_profile.rule' in match.tags) ret = [] for profile in profile_list: - codec = matches.previous(profile, lambda match: match.name == 'video_codec') + codec = matches.at_span(profile.span, lambda match: match.name == 'video_codec', 0) + if not codec: + codec = matches.previous(profile, lambda match: match.name == 'video_codec') if not codec: codec = matches.next(profile, lambda match: match.name == 'video_codec') if not codec: diff --git a/libs/guessit/rules/properties/website.py b/libs/guessit/rules/properties/website.py index 8563ea16..00dfadd1 100644 --- a/libs/guessit/rules/properties/website.py +++ b/libs/guessit/rules/properties/website.py @@ -7,25 +7,37 @@ from pkg_resources import resource_stream # @UnresolvedImport from rebulk.remodule import re from rebulk import Rebulk, Rule, RemoveMatch +from ..common import seps +from ..common.formatters import cleanup +from ..common.pattern import is_disabled +from ..common.validators import seps_surround from ...reutils import build_or_pattern -def website(): +def website(config): """ Builder for rebulk object. + + :param config: rule configuration + :type config: dict :return: Created Rebulk object :rtype: Rebulk """ - rebulk = Rebulk().regex_defaults(flags=re.IGNORECASE) + rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'website')) + rebulk = rebulk.regex_defaults(flags=re.IGNORECASE).string_defaults(ignore_case=True) rebulk.defaults(name="website") - tlds = [l.strip().decode('utf-8') - for l in resource_stream('guessit', 'tlds-alpha-by-domain.txt').readlines() - if b'--' not in l][1:] # All registered domain extension + with resource_stream('guessit', 'tlds-alpha-by-domain.txt') as tld_file: + tlds = [ + tld.strip().decode('utf-8') + for tld in tld_file.readlines() + if b'--' not in tld + ][1:] # All registered domain extension - safe_tlds = ['com', 'org', 'net'] # For sure a website extension - safe_subdomains = ['www'] # For sure a website subdomain - safe_prefix = ['co', 'com', 'org', 'net'] # Those words before a tlds are sure + safe_tlds = config['safe_tlds'] # For sure a website extension + safe_subdomains = config['safe_subdomains'] # For sure a website subdomain + safe_prefix = config['safe_prefixes'] # Those words before a tlds are sure + website_prefixes = config['prefixes'] rebulk.regex(r'(?:[^a-z0-9]|^)((?:'+build_or_pattern(safe_subdomains) + r'\.)+(?:[a-z-]+\.)+(?:'+build_or_pattern(tlds) + @@ -41,6 +53,9 @@ def website(): r'))(?:[^a-z0-9]|$)', safe_subdomains=safe_subdomains, safe_prefix=safe_prefix, tlds=tlds, children=True) + rebulk.string(*website_prefixes, + validator=seps_surround, private=True, tags=['website.prefix']) + class PreferTitleOverWebsite(Rule): """ If found match is more likely a title, remove website. @@ -57,11 +72,35 @@ def website(): def when(self, matches, context): to_remove = [] for website_match in matches.named('website'): - suffix = matches.next(website_match, PreferTitleOverWebsite.valid_followers, 0) - if suffix: - to_remove.append(website_match) + safe = False + for safe_start in safe_subdomains + safe_prefix: + if website_match.value.lower().startswith(safe_start): + safe = True + break + if not safe: + suffix = matches.next(website_match, PreferTitleOverWebsite.valid_followers, 0) + if suffix: + to_remove.append(website_match) return to_remove - rebulk.rules(PreferTitleOverWebsite) + rebulk.rules(PreferTitleOverWebsite, ValidateWebsitePrefix) return rebulk + + +class ValidateWebsitePrefix(Rule): + """ + Validate website prefixes + """ + priority = 64 + consequence = RemoveMatch + + def when(self, matches, context): + to_remove = [] + for prefix in matches.tagged('website.prefix'): + website_match = matches.next(prefix, predicate=lambda match: match.name == 'website', index=0) + if (not website_match or + matches.holes(prefix.end, website_match.start, + formatter=cleanup, seps=seps, predicate=lambda match: match.value)): + to_remove.append(prefix) + return to_remove diff --git a/libs/guessit/test/config/dummy.txt b/libs/guessit/test/config/dummy.txt new file mode 100644 index 00000000..7d6ca31b --- /dev/null +++ b/libs/guessit/test/config/dummy.txt @@ -0,0 +1 @@ +Not a configuration file \ No newline at end of file diff --git a/libs/guessit/test/config/test.json b/libs/guessit/test/config/test.json new file mode 100644 index 00000000..22f45d2a --- /dev/null +++ b/libs/guessit/test/config/test.json @@ -0,0 +1,4 @@ +{ + "expected_title": ["The 100", "OSS 117"], + "yaml": false +} diff --git a/libs/guessit/test/config/test.yaml b/libs/guessit/test/config/test.yaml new file mode 100644 index 00000000..6a4dfe15 --- /dev/null +++ b/libs/guessit/test/config/test.yaml @@ -0,0 +1,4 @@ +expected_title: + - The 100 + - OSS 117 +yaml: True diff --git a/libs/guessit/test/config/test.yml b/libs/guessit/test/config/test.yml new file mode 100644 index 00000000..6a4dfe15 --- /dev/null +++ b/libs/guessit/test/config/test.yml @@ -0,0 +1,4 @@ +expected_title: + - The 100 + - OSS 117 +yaml: True diff --git a/libs/guessit/test/enable_disable_properties.yml b/libs/guessit/test/enable_disable_properties.yml new file mode 100644 index 00000000..86c659d6 --- /dev/null +++ b/libs/guessit/test/enable_disable_properties.yml @@ -0,0 +1,335 @@ +? vorbis +: options: --exclude audio_codec + -audio_codec: Vorbis + +? DTS-ES +: options: --exclude audio_profile + audio_codec: DTS + -audio_profile: Extended Surround + +? DTS.ES +: options: --include audio_codec + audio_codec: DTS + -audio_profile: Extended Surround + +? 5.1 +? 5ch +? 6ch +: options: --exclude audio_channels + -audio_channels: '5.1' + +? Movie Title-x01-Other Title.mkv +? Movie Title-x01-Other Title +? directory/Movie Title-x01-Other Title/file.mkv +: options: --exclude bonus + -bonus: 1 + -bonus_title: Other Title + +? Title-x02-Bonus Title.mkv +: options: --include bonus + bonus: 2 + -bonus_title: Other Title + +? cd 1of3 +: options: --exclude cd + -cd: 1 + -cd_count: 3 + +? This.Is.Us +: options: --exclude country + title: This Is Us + -country: US + +? 2015.01.31 +: options: --exclude date + year: 2015 + -date: 2015-01-31 + +? Something 2 mar 2013) +: options: --exclude date + -date: 2013-03-02 + +? 2012 2009 S01E02 2015 # If no year is marked, the second one is guessed. +: options: --exclude year + -year: 2009 + +? Director's cut +: options: --exclude edition + -edition: Director's Cut + +? 2x5 +? 2X5 +? 02x05 +? 2X05 +? 02x5 +? S02E05 +? s02e05 +? s02e5 +? s2e05 +? s02ep05 +? s2EP5 +: options: --exclude season + -season: 2 + -episode: 5 + +? 2x6 +? 2X6 +? 02x06 +? 2X06 +? 02x6 +? S02E06 +? s02e06 +? s02e6 +? s2e06 +? s02ep06 +? s2EP6 +: options: --exclude episode + -season: 2 + -episode: 6 + +? serie Season 2 other +: options: --exclude season + -season: 2 + +? Some Dummy Directory/S02 Some Series/E01-Episode title.mkv +: options: --exclude episode_title + -episode_title: Episode title + season: 2 + episode: 1 + +? Another Dummy Directory/S02 Some Series/E01-Episode title.mkv +: options: --include season --include episode + -episode_title: Episode title + season: 2 + episode: 1 + +# pattern contains season and episode: it wont work enabling only one +? Some Series S03E01E02 +: options: --include episode + -season: 3 + -episode: [1, 2] + +# pattern contains season and episode: it wont work enabling only one +? Another Series S04E01E02 +: options: --include season + -season: 4 + -episode: [1, 2] + +? Show.Name.Season.4.Episode.1 +: options: --include episode + -season: 4 + episode: 1 + +? Another.Show.Name.Season.4.Episode.1 +: options: --include season + season: 4 + -episode: 1 + +? Some Series S01 02 03 +: options: --exclude season + -season: [1, 2, 3] + +? Some Series E01 02 04 +: options: --exclude episode + -episode: [1, 2, 4] + +? A very special episode s06 special +: options: -t episode --exclude episode_details + season: 6 + -episode_details: Special + +? S01D02.3-5-GROUP +: options: --exclude disc + -season: 1 + -disc: [2, 3, 4, 5] + -episode: [2, 3, 4, 5] + +? S01D02&4-6&8 +: options: --exclude season + -season: 1 + -disc: [2, 4, 5, 6, 8] + -episode: [2, 4, 5, 6, 8] + +? Film Title-f01-Series Title.mkv +: options: --exclude film + -film: 1 + -film_title: Film Title + +? Another Film Title-f01-Series Title.mkv +: options: --exclude film_title + film: 1 + -film_title: Film Title + +? English +? .ENG. +: options: --exclude language + -language: English + +? SubFrench +? SubFr +? STFr +: options: --exclude subtitle_language + -language: French + -subtitle_language: French + +? ST.FR +: options: --exclude subtitle_language + language: French + -subtitle_language: French + +? ENG.-.sub.FR +? ENG.-.FR Sub +: options: --include language + language: [English, French] + -subtitle_language: French + +? ENG.-.SubFR +: options: --include language + language: English + -subtitle_language: French + +? ENG.-.FRSUB +? ENG.-.FRSUBS +? ENG.-.FR-SUBS +: options: --include subtitle_language + -language: English + subtitle_language: French + +? DVD.Real.XViD +? DVD.fix.XViD +: options: --exclude other + -other: Fix + -proper_count: 1 + +? Part 3 +? Part III +? Part Three +? Part Trois +? Part3 +: options: --exclude part + -part: 3 + +? Some.Title.XViD-by.Artik[SEDG].avi +: options: --exclude release_group + -release_group: Artik[SEDG] + +? "[ABC] Some.Title.avi" +? some/folder/[ABC]Some.Title.avi +: options: --exclude release_group + -release_group: ABC + +? 360p +? 360px +? "360" +? +500x360 +: options: --exclude screen_size + -screen_size: 360p + +? 640x360 +: options: --exclude aspect_ratio + screen_size: 360p + -aspect_ratio: 1.778 + +? 8196x4320 +: options: --exclude screen_size + -screen_size: 4320p + -aspect_ratio: 1.897 + +? 4.3gb +: options: --exclude size + -size: 4.3GB + +? VhS_rip +? VHS.RIP +: options: --exclude source + -source: VHS + -other: Rip + +? DVD.RIP +: options: --include other + -source: DVD + -other: Rip + +? Title Only.avi +: options: --exclude title + -title: Title Only + +? h265 +? x265 +? h.265 +? x.265 +? hevc +: options: --exclude video_codec + -video_codec: H.265 + +? hevc10 +: options: --include color_depth + -video_codec: H.265 + -color_depth: 10-bit + +? HEVC-YUV420P10 +: options: --include color_depth + -video_codec: H.265 + color_depth: 10-bit + +? h265-HP +: options: --exclude video_profile + video_codec: H.265 + -video_profile: High + +? House.of.Cards.2013.S02E03.1080p.NF.WEBRip.DD5.1.x264-NTb.mkv +? House.of.Cards.2013.S02E03.1080p.Netflix.WEBRip.DD5.1.x264-NTb.mkv +: options: --exclude streaming_service + -streaming_service: Netflix + +? wawa.co.uk +: options: --exclude website + -website: wawa.co.uk + +? movie.mkv +: options: --exclude mimetype + -mimetype: video/x-matroska + +? another movie.mkv +: options: --exclude container + -container: mkv + +? series s02e01 +: options: --exclude type + -type: episode + +? series s02e01 +: options: --exclude type + -type: episode + +? Hotel.Hell.S01E01.720p.DD5.1.448kbps-ALANiS +: options: --exclude audio_bit_rate + -audio_bit_rate: 448Kbps + +? Katy Perry - Pepsi & Billboard Summer Beats Concert Series 2012 1080i HDTV 20 Mbps DD2.0 MPEG2-TrollHD.ts +: options: --exclude video_bit_rate + -video_bit_rate: 20Mbps + +? "[Figmentos] Monster 34 - At the End of Darkness [781219F1].mkv" +: options: --exclude crc32 + -crc32: 781219F1 + +? 1080p25 +: options: --exclude frame_rate + screen_size: 1080p + -frame_rate: 25fps + +? 1080p25 +: options: --exclude screen_size + -screen_size: 1080p + -frame_rate: 25fps + +? 1080p25 +: options: --include frame_rate + -screen_size: 1080p + -frame_rate: 25fps + +? 1080p 30fps +: options: --exclude screen_size + -screen_size: 1080p + frame_rate: 30fps diff --git a/libs/guessit/test/episodes.yml b/libs/guessit/test/episodes.yml index adc4755e..f7b5c3df 100644 --- a/libs/guessit/test/episodes.yml +++ b/libs/guessit/test/episodes.yml @@ -6,8 +6,8 @@ season: 2 episode: 5 episode_title: Vaginatown - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: 0TV container: avi @@ -18,8 +18,8 @@ episode_title: Hello, Bandit language: English subtitle_language: French - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: AlFleNi-TeaM website: tvu.org.ru container: avi @@ -29,8 +29,8 @@ season: 1 episode: 3 episode_title: Right Place, Wrong Time - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: NoTV ? Series/Duckman/Duckman - S1E13 Joking The Chicken (unedited).avi @@ -89,7 +89,7 @@ episode: 2 episode_title: 65 Million Years Off language: english - format: DVD + source: DVD other: Complete ? series/Psych/Psych S02 Season 2 Complete English DVD/Psych.S02E03.Psy.Vs.Psy.Français.srt @@ -97,7 +97,7 @@ season: 2 episode: 3 episode_title: Psy Vs Psy - format: DVD + source: DVD language: English subtitle_language: French other: Complete @@ -107,7 +107,7 @@ season: 1 episode: 1 episode_title: Toutes Couleurs Unies - format: DVB + source: Digital TV release_group: Kceb language: french website: tvu.org.ru @@ -132,15 +132,16 @@ episode_title: 18-5-4 language: english subtitle_language: french - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: AlFleNi-TeaM website: tvu.org.ru ? series/__ Incomplete __/Dr Slump (Catalan)/Dr._Slump_-_003_DVB-Rip_Catalan_by_kelf.avi : title: Dr Slump episode: 3 - format: DVB + source: Digital TV + other: Rip language: catalan # Disabling this test because it just doesn't looks like a serie ... @@ -166,7 +167,8 @@ season: 4 episode: 7 episode_title: Cherokee Hair Tampons - format: DVD + source: DVD + other: Rip website: tvu.org.ru ? Series/Kaamelott/Kaamelott - Livre V - Ep 23 - Le Forfait.avi @@ -192,16 +194,17 @@ episode_format: Minisode episode: 1 episode_title: Good Cop Bad Cop - format: WEBRip - video_codec: XviD + source: Web + other: Rip + video_codec: Xvid ? Series/My Name Is Earl/My.Name.Is.Earl.S01Extras.-.Bad.Karma.DVDRip.XviD.avi : title: My Name Is Earl season: 1 episode_title: Extras - Bad Karma - format: DVD - episode_details: Extras - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid ? series/Freaks And Geeks/Season 1/Episode 4 - Kim Kelly Is My Friend-eng(1).srt : title: Freaks And Geeks @@ -256,7 +259,7 @@ : title: new girl season: 1 episode: 17 - format: HDTV + source: HDTV release_group: lol ? Kaamelott - 5x44x45x46x47x48x49x50.avi @@ -296,25 +299,24 @@ season: 1 episode: 3 episode_title: Health Care - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: LOL ? /Volumes/data-1/Series/Futurama/Season 3/Futurama_-_S03_DVD_Bonus_-_Deleted_Scenes_Part_3.ogm : title: Futurama season: 3 part: 3 + source: DVD other: Bonus - episode_title: Deleted Scenes - format: DVD ? Ben.and.Kate.S01E02.720p.HDTV.X264-DIMENSION.mkv : title: Ben and Kate season: 1 episode: 2 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: DIMENSION ? /volume1/TV Series/Drawn Together/Season 1/Drawn Together 1x04 Requiem for a Reality Show.avi @@ -328,10 +330,10 @@ season: 5 episode: 6 screen_size: 720p - format: WEB-DL + source: Web audio_channels: "5.1" - audio_codec: DolbyDigital - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 release_group: CtrlHD ? /media/bdc64bfe-e36f-4af8-b550-e6fd2dfaa507/TV_Shows/Doctor Who (2005)/Saison 6/Doctor Who (2005) - S06E13 - The Wedding of River Song.mkv @@ -354,10 +356,10 @@ episode: 3 episode_title: Adventures in Baby-Getting screen_size: 720p - format: WEB-DL + source: Web audio_channels: "5.1" - audio_codec: DolbyDigital - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 release_group: CtrlHD ? /home/disaster/Videos/TV/Merlin/merlin_2008.5x02.arthurs_bane_part_two.repack.720p_hdtv_x264-fov.mkv @@ -367,8 +369,8 @@ part: 2 episode_title: arthurs bane screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: fov year: 2008 other: Proper @@ -386,28 +388,28 @@ episode: 18 episode_title: Sheltered screen_size: 720p - format: WEB-DL + source: Web audio_channels: "5.1" - audio_codec: DolbyDigital - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 ? Game of Thrones S03E06 1080i HDTV DD5.1 MPEG2-TrollHD.ts : title: Game of Thrones season: 3 episode: 6 screen_size: 1080i - format: HDTV + source: HDTV audio_channels: "5.1" - audio_codec: DolbyDigital - video_codec: Mpeg2 + audio_codec: Dolby Digital + video_codec: MPEG-2 release_group: TrollHD ? gossip.girl.s01e18.hdtv.xvid-2hd.eng.srt : title: gossip girl season: 1 episode: 18 - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: 2hd subtitle_language: english @@ -416,8 +418,8 @@ season: 3 episode: [1, 2] screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: IMMERSE ? Wheels.S03E01-02.720p.HDTV.x264-IMMERSE.mkv @@ -425,8 +427,8 @@ season: 3 episode: [1, 2] screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: IMMERSE ? Wheels.S03E01-E02.720p.HDTV.x264-IMMERSE.mkv @@ -434,8 +436,8 @@ season: 3 episode: [1, 2] screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: IMMERSE ? Wheels.S03E01-04.720p.HDTV.x264-IMMERSE.mkv @@ -443,8 +445,8 @@ season: 3 episode: [1, 2, 3, 4] screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: IMMERSE ? Marvels.Agents.of.S.H.I.E.L.D-S01E06.720p.HDTV.X264-DIMENSION.mkv @@ -452,8 +454,8 @@ season: 1 episode: 6 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: DIMENSION ? Marvels.Agents.of.S.H.I.E.L.D.S01E06.720p.HDTV.X264-DIMENSION.mkv @@ -461,8 +463,8 @@ season: 1 episode: 6 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: DIMENSION ? Marvels.Agents.of.S.H.I.E.L.D..S01E06.720p.HDTV.X264-DIMENSION.mkv @@ -470,8 +472,8 @@ season: 1 episode: 6 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: DIMENSION ? Series/Friday Night Lights/Season 1/Friday Night Lights S01E19 - Ch-Ch-Ch-Ch-Changes.avi @@ -483,22 +485,24 @@ ? Dexter Saison VII FRENCH.BDRip.XviD-MiND.nfo : title: Dexter season: 7 - video_codec: XviD + video_codec: Xvid language: French - format: BluRay + source: Blu-ray + other: Rip release_group: MiND ? Dexter Saison sept FRENCH.BDRip.XviD-MiND.nfo : title: Dexter season: 7 - video_codec: XviD + video_codec: Xvid language: French - format: BluRay + source: Blu-ray + other: Rip release_group: MiND ? "Pokémon S16 - E29 - 1280*720 HDTV VF.mkv" : title: Pokémon - format: HDTV + source: HDTV language: French season: 16 episode: 29 @@ -506,20 +510,20 @@ ? One.Piece.E576.VOSTFR.720p.HDTV.x264-MARINE-FORD.mkv : episode: 576 - video_codec: h264 - format: HDTV + video_codec: H.264 + source: HDTV title: One Piece release_group: MARINE-FORD subtitle_language: French screen_size: 720p ? Dexter.S08E12.FINAL.MULTi.1080p.BluRay.x264-MiND.mkv -: video_codec: h264 +: video_codec: H.264 episode: 12 season: 8 - format: BluRay + source: Blu-ray title: Dexter - other: FINAL + episode_details: Final language: Multiple languages release_group: MiND screen_size: 1080p @@ -536,30 +540,30 @@ screen_size: 720p season: 1 title: Falling Skies - video_codec: h264 - other: HDLight + video_codec: H.264 + other: Micro HD ? Sleepy.Hollow.S01E09.720p.WEB-DL.DD5.1.H.264-BP.mkv : episode: 9 - video_codec: h264 - format: WEB-DL + video_codec: H.264 + source: Web title: Sleepy Hollow audio_channels: "5.1" screen_size: 720p season: 1 - video_profile: BP - audio_codec: DolbyDigital +# video_profile: BP # TODO: related to https://github.com/guessit-io/guessit/issues/458#issuecomment-305719715 + audio_codec: Dolby Digital ? Sleepy.Hollow.S01E09.720p.WEB-DL.DD5.1.H.264-BS.mkv : episode: 9 - video_codec: h264 - format: WEB-DL + video_codec: H.264 + source: Web title: Sleepy Hollow audio_channels: "5.1" screen_size: 720p season: 1 release_group: BS - audio_codec: DolbyDigital + audio_codec: Dolby Digital ? Battlestar.Galactica.S00.Pilot.FRENCH.DVDRip.XviD-NOTAG.avi : title: Battlestar Galactica @@ -567,8 +571,9 @@ episode_details: Pilot episode_title: Pilot language: French - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: NOTAG ? The Big Bang Theory S00E00 Unaired Pilot VOSTFR TVRip XviD-VioCs @@ -576,8 +581,9 @@ season: 0 episode: 0 subtitle_language: French - format: TV - video_codec: XviD + source: TV + other: Rip + video_codec: Xvid release_group: VioCs episode_details: [Unaired, Pilot] @@ -585,10 +591,10 @@ : title: The Big Bang Theory season: 1 episode: 0 - format: TV - video_codec: XviD + source: TV + video_codec: Xvid release_group: GIGGITY - other: Proper + other: [Proper, Rip] proper_count: 1 episode_details: [Unaired, Pilot] @@ -598,8 +604,8 @@ year: 2014 episode: 18 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: KILLERS ? 2.Broke.Girls.S03E10.480p.HDTV.x264-mSD.mkv @@ -607,28 +613,15 @@ season: 3 episode: 10 screen_size: 480p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: mSD -? House.of.Cards.2013.S02E03.1080p.NF.WEBRip.DD5.1.x264-NTb.mkv -: title: House of Cards - year: 2013 - season: 2 - episode: 3 - screen_size: 1080p - other: Netflix - format: WEBRip - audio_channels: "5.1" - audio_codec: DolbyDigital - video_codec: h264 - release_group: NTb - ? the.100.109.hdtv-lol.mp4 : title: the 100 season: 1 episode: 9 - format: HDTV + source: HDTV release_group: lol ? Criminal.Minds.5x03.Reckoner.ENG.-.sub.FR.HDTV.XviD-STi.[tvu.org.ru].avi @@ -637,8 +630,8 @@ subtitle_language: French season: 5 episode: 3 - video_codec: XviD - format: HDTV + video_codec: Xvid + source: HDTV website: tvu.org.ru release_group: STi episode_title: Reckoner @@ -650,7 +643,7 @@ ? '[Evil-Saizen]_Laughing_Salesman_14_[DVD][1C98686A].mkv' : crc32: 1C98686A episode: 14 - format: DVD + source: DVD release_group: Evil-Saizen title: Laughing Salesman @@ -679,22 +672,22 @@ : audio_codec: AAC crc32: 99E8E009 episode: 1 - format: BluRay + source: Blu-ray release_group: Daisei screen_size: 720p title: Free!:Iwatobi Swim Club - video_profile: 10bit + color_depth: 10-bit ? '[Tsundere] Boku wa Tomodachi ga Sukunai - 03 [BDRip h264 1920x1080 10bit FLAC][AF0C22CC].mkv' : audio_codec: FLAC crc32: AF0C22CC episode: 3 - format: BluRay + source: Blu-ray release_group: Tsundere screen_size: 1080p title: Boku wa Tomodachi ga Sukunai - video_codec: h264 - video_profile: 10bit + video_codec: H.264 + color_depth: 10-bit ? '[t.3.3.d]_Mikakunin_de_Shinkoukei_-_12_[720p][5DDC1352].mkv' : crc32: 5DDC1352 @@ -709,12 +702,12 @@ release_group: Anime-Koi screen_size: 720p title: Sabagebu! - video_codec: h264 + video_codec: H.264 ? '[aprm-Diogo4D] [BD][1080p] Nagi no Asukara 08 [4D102B7C].mkv' : crc32: 4D102B7C episode: 8 - format: BluRay + source: Blu-ray release_group: aprm-Diogo4D screen_size: 1080p title: Nagi no Asukara @@ -747,7 +740,7 @@ ? '[DeadFish] Tari Tari - 01 [BD][720p][AAC].mp4' : audio_codec: AAC episode: 1 - format: BluRay + source: Blu-ray release_group: DeadFish screen_size: 720p title: Tari Tari @@ -758,12 +751,12 @@ release_group: NoobSubs screen_size: 720p title: Sword Art Online II - video_profile: 8bit + color_depth: 8-bit ? '[DeadFish] 01 - Tari Tari [BD][720p][AAC].mp4' : audio_codec: AAC episode: 1 - format: BluRay + source: Blu-ray release_group: DeadFish screen_size: 720p title: Tari Tari @@ -774,12 +767,12 @@ release_group: NoobSubs screen_size: 720p title: Sword Art Online II - video_profile: 8bit + color_depth: 8-bit ? '[DeadFish] 12 - Tari Tari [BD][720p][AAC].mp4' : audio_codec: AAC episode: 12 - format: BluRay + source: Blu-ray release_group: DeadFish screen_size: 720p title: Tari Tari @@ -787,7 +780,7 @@ ? Something.Season.2.1of4.Ep.Title.HDTV.torrent : episode_count: 4 episode: 1 - format: HDTV + source: HDTV season: 2 title: Something episode_title: Title @@ -796,7 +789,7 @@ ? Something.Season.2of5.3of9.Ep.Title.HDTV.torrent : episode_count: 9 episode: 3 - format: HDTV + source: HDTV season: 2 season_count: 5 title: Something @@ -804,7 +797,7 @@ container: torrent ? Something.Other.Season.3of5.Complete.HDTV.torrent -: format: HDTV +: source: HDTV other: Complete season: 3 season_count: 5 @@ -826,22 +819,22 @@ ? W2Test.123.HDTV.XViD-FlexGet : episode: 23 season: 1 - format: HDTV + source: HDTV release_group: FlexGet title: W2Test - video_codec: XviD + video_codec: Xvid ? W2Test.123.HDTV.XViD-FlexGet : options: --episode-prefer-number episode: 123 - format: HDTV + source: HDTV release_group: FlexGet title: W2Test - video_codec: XviD + video_codec: Xvid ? FooBar.0307.PDTV-FlexGet : episode: 7 - format: DVB + source: Digital TV release_group: FlexGet season: 3 title: FooBar @@ -850,53 +843,51 @@ ? FooBar.307.PDTV-FlexGet : options: --episode-prefer-number episode: 307 - format: DVB + source: Digital TV release_group: FlexGet title: FooBar ? FooBar.07.PDTV-FlexGet -: options: --episode-prefer-number - episode: 7 - format: DVB +: episode: 7 + source: Digital TV release_group: FlexGet title: FooBar ? FooBar.7.PDTV-FlexGet -: options: --episode-prefer-number - episode: 7 - format: DVB +: episode: 7 + source: Digital TV release_group: FlexGet title: FooBar ? FooBar.0307.PDTV-FlexGet : episode: 7 - format: DVB + source: Digital TV release_group: FlexGet season: 3 title: FooBar ? FooBar.307.PDTV-FlexGet : episode: 7 - format: DVB + source: Digital TV release_group: FlexGet season: 3 title: FooBar ? FooBar.07.PDTV-FlexGet : episode: 7 - format: DVB + source: Digital TV release_group: FlexGet title: FooBar ? FooBar.07v4.PDTV-FlexGet : episode: 7 version: 4 - format: DVB + source: Digital TV release_group: FlexGet title: FooBar ? FooBar.7.PDTV-FlexGet -: format: DVB +: source: Digital TV release_group: FlexGet title: FooBar 7 type: movie @@ -904,7 +895,7 @@ ? FooBar.7.PDTV-FlexGet : options: -t episode episode: 7 - format: DVB + source: Digital TV release_group: FlexGet title: FooBar @@ -912,13 +903,13 @@ : options: -t episode episode: 7 version: 3 - format: DVB + source: Digital TV release_group: FlexGet title: FooBar ? Test.S02E01.hdtv.real.proper : episode: 1 - format: HDTV + source: HDTV other: Proper proper_count: 2 season: 2 @@ -926,7 +917,7 @@ ? Real.Test.S02E01.hdtv.proper : episode: 1 - format: HDTV + source: HDTV other: Proper proper_count: 1 season: 2 @@ -934,7 +925,7 @@ ? Test.Real.S02E01.hdtv.proper : episode: 1 - format: HDTV + source: HDTV other: Proper proper_count: 1 season: 2 @@ -942,7 +933,7 @@ ? Test.S02E01.hdtv.proper : episode: 1 - format: HDTV + source: HDTV other: Proper proper_count: 1 season: 2 @@ -950,7 +941,7 @@ ? Test.S02E01.hdtv.real.repack.proper : episode: 1 - format: HDTV + source: HDTV other: Proper proper_count: 3 season: 2 @@ -958,10 +949,10 @@ ? Date.Show.03-29-2012.HDTV.XViD-FlexGet : date: 2012-03-29 - format: HDTV + source: HDTV release_group: FlexGet title: Date Show - video_codec: XviD + video_codec: Xvid ? Something.1x5.Season.Complete-FlexGet : episode: 5 @@ -999,13 +990,13 @@ audio_codec: AAC country: US episode: 14 - format: HDTV + source: HDTV release_group: NOGRP screen_size: 720p season: 2013 title: FlexGet episode_title: Title Here - video_codec: h264 + video_codec: H.264 year: 2013 ? FlexGet.14.of.21.Title.Here.720p.HDTV.AAC5.1.x264-NOGRP @@ -1013,25 +1004,25 @@ audio_codec: AAC episode_count: 21 episode: 14 - format: HDTV + source: HDTV release_group: NOGRP screen_size: 720p title: FlexGet episode_title: Title Here - video_codec: h264 + video_codec: H.264 ? FlexGet.Series.2013.14.of.21.Title.Here.720p.HDTV.AAC5.1.x264-NOGRP : audio_channels: '5.1' audio_codec: AAC episode_count: 21 episode: 14 - format: HDTV + source: HDTV release_group: NOGRP screen_size: 720p season: 2013 - title: FlexGet + title: FlexGet Series episode_title: Title Here - video_codec: h264 + video_codec: H.264 year: 2013 ? Something.S04E05E09 @@ -1054,12 +1045,14 @@ title: FooBar ? FooBar 360 -: screen_size: 360p +: season: 3 + episode: 60 title: FooBar + -screen_size: 360p ? BarFood christmas special HDTV : options: --expected-title BarFood - format: HDTV + source: HDTV title: BarFood episode_title: christmas special episode_details: Special @@ -1081,47 +1074,47 @@ ? Test.13.HDTV-Ignored : episode: 13 - format: HDTV + source: HDTV release_group: Ignored title: Test ? Test.13.HDTV-Ignored : options: --expected-series test episode: 13 - format: HDTV + source: HDTV release_group: Ignored title: Test ? Test.13.HDTV-Ignored : title: Test episode: 13 - format: HDTV + source: HDTV release_group: Ignored ? Test.13.HDTV-Ignored : episode: 13 - format: HDTV + source: HDTV release_group: Ignored title: Test ? Test.13.HDTV-FlexGet : episode: 13 - format: HDTV + source: HDTV release_group: FlexGet title: Test ? Test.14.HDTV-Name : episode: 14 - format: HDTV + source: HDTV release_group: Name title: Test ? Real.Time.With.Bill.Maher.2014.10.31.HDTV.XviD-AFG.avi : date: 2014-10-31 - format: HDTV + source: HDTV release_group: AFG title: Real Time With Bill Maher - video_codec: XviD + video_codec: Xvid ? Arrow.S03E21.Al.Sah-Him.1080p.WEB-DL.DD5.1.H.264-BS.mkv : title: Arrow @@ -1129,11 +1122,11 @@ episode: 21 episode_title: Al Sah-Him screen_size: 1080p - audio_codec: DolbyDigital + audio_codec: Dolby Digital audio_channels: "5.1" - video_codec: h264 + video_codec: H.264 release_group: BS - format: WEB-DL + source: Web ? How to Make It in America - S02E06 - I'm Sorry, Who's Yosi?.mkv : title: How to Make It in America @@ -1143,60 +1136,27 @@ ? 24.S05E07.FRENCH.DVDRip.XviD-FiXi0N.avi : episode: 7 - format: DVD + source: DVD + other: Rip language: fr season: 5 title: '24' - video_codec: XviD + video_codec: Xvid release_group: FiXi0N ? 12.Monkeys.S01E12.FRENCH.BDRip.x264-VENUE.mkv : episode: 12 - format: BluRay + source: Blu-ray + other: Rip language: fr release_group: VENUE season: 1 title: 12 Monkeys - video_codec: h264 - -? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.720p.CC.WEBRip.AAC2.0.x264-BTW.mkv -: audio_channels: '2.0' - audio_codec: AAC - date: 2015-07-01 - format: WEBRip - other: [Extended, CC] - release_group: BTW - screen_size: 720p - title: The Daily Show - episode_title: Kirsten Gillibrand - video_codec: h264 - -? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.Interview.720p.CC.WEBRip.AAC2.0.x264-BTW.mkv -: audio_channels: '2.0' - audio_codec: AAC - date: 2015-07-01 - format: WEBRip - other: CC - release_group: BTW - screen_size: 720p - title: The Daily Show - episode_title: Kirsten Gillibrand Extended Interview - video_codec: h264 - -? The.Daily.Show.2015.07.02.Sarah.Vowell.CC.WEBRip.AAC2.0.x264-BTW.mkv -: audio_channels: '2.0' - audio_codec: AAC - date: 2015-07-02 - format: WEBRip - other: CC - release_group: BTW - title: The Daily Show - episode_title: Sarah Vowell - video_codec: h264 + video_codec: H.264 ? 90.Day.Fiance.S02E07.I.Have.To.Tell.You.Something.720p.HDTV.x264-W4F : episode: 7 - format: HDTV + source: HDTV screen_size: 720p season: 2 title: 90 Day Fiance @@ -1205,42 +1165,44 @@ ? Doctor.Who.2005.S04E06.FRENCH.LD.DVDRip.XviD-TRACKS.avi : episode: 6 - format: DVD + source: DVD language: fr release_group: TRACKS season: 4 title: Doctor Who - other: LD - video_codec: XviD + other: [Line Dubbed, Rip] + video_codec: Xvid year: 2005 ? Astro.Le.Petit.Robot.S01E01+02.FRENCH.DVDRiP.X264.INT-BOOLZ.mkv : episode: [1, 2] - format: DVD + source: DVD + other: Rip language: fr release_group: INT-BOOLZ season: 1 title: Astro Le Petit Robot - video_codec: h264 + video_codec: H.264 ? Annika.Bengtzon.2012.E01.Le.Testament.De.Nobel.FRENCH.DVDRiP.XViD-STVFRV.avi : episode: 1 - format: DVD + source: DVD + other: Rip language: fr release_group: STVFRV title: Annika Bengtzon episode_title: Le Testament De Nobel - video_codec: XviD + video_codec: Xvid year: 2012 ? Dead.Set.02.FRENCH.LD.DVDRip.XviD-EPZ.avi : episode: 2 - format: DVD + source: DVD language: fr - other: LD + other: [Line Dubbed, Rip] release_group: EPZ title: Dead Set - video_codec: XviD + video_codec: Xvid ? Phineas and Ferb S01E00 & S01E01 & S01E02 : episode: [0, 1, 2] @@ -1249,11 +1211,11 @@ ? Show.Name.S01E02.S01E03.HDTV.XViD.Etc-Group : episode: [2, 3] - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - S01E02 - S01E03 - S01E04 - Ep Name : episode: [2, 3, 4] @@ -1263,11 +1225,11 @@ ? Show.Name.1x02.1x03.HDTV.XViD.Etc-Group : episode: [2, 3] - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - 1x02 - 1x03 - 1x04 - Ep Name : episode: [2, 3, 4] @@ -1277,11 +1239,11 @@ ? Show.Name.S01E02.HDTV.XViD.Etc-Group : episode: 2 - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - S01E02 - My Ep Name : episode: 2 @@ -1297,11 +1259,11 @@ ? Show.Name.S01E02E03.HDTV.XViD.Etc-Group : episode: [2, 3] - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - S01E02-03 - My Ep Name : episode: [2, 3] @@ -1316,11 +1278,11 @@ ? Show_Name.1x02.HDTV_XViD_Etc-Group : episode: 2 - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - 1x02 - My Ep Name : episode: 2 @@ -1330,11 +1292,11 @@ ? Show_Name.1x02x03x04.HDTV_XViD_Etc-Group : episode: [2, 3, 4] - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show Name - 1x02-03-04 - My Ep Name : episode: [2, 3, 4] @@ -1347,25 +1309,25 @@ : date: 2010-11-23 season: 1 episode: 0 - format: HDTV + source: HDTV release_group: Etc-Group title: Show Name episode_title: Event - video_codec: XviD + video_codec: Xvid ? Show.Name.101.Event.2010.11.23.HDTV.XViD.Etc-Group : date: 2010-11-23 season: 1 episode: 1 - format: HDTV + source: HDTV release_group: Etc-Group title: Show Name episode_title: Event - video_codec: XviD + video_codec: Xvid ? Show.Name.2010.11.23.HDTV.XViD.Etc-Group : date: 2010-11-23 - format: HDTV + source: HDTV release_group: Etc-Group title: Show Name @@ -1381,11 +1343,11 @@ episode_title: Ep Name ? Show.Name.S01.HDTV.XViD.Etc-Group -: format: HDTV +: source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? Show.Name.E02-03 : episode: [2, 3] @@ -1404,8 +1366,8 @@ ? Show.Name.Part.3.HDTV.XViD.Etc-Group : part: 3 title: Show Name - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: Etc-Group type: movie # Fallback to movie type because we can't tell it's a series ... @@ -1427,11 +1389,11 @@ ? Show.Name.102.HDTV.XViD.Etc-Group : episode: 2 - format: HDTV + source: HDTV release_group: Etc-Group season: 1 title: Show Name - video_codec: XviD + video_codec: Xvid ? '[HorribleSubs] Maria the Virgin Witch - 01 [720p].mkv' : episode: 1 @@ -1440,29 +1402,26 @@ title: Maria the Virgin Witch ? '[ISLAND]One_Piece_679_[VOSTFR]_[V1]_[8bit]_[720p]_[EB7838FC].mp4' -: options: -E - crc32: EB7838FC +: crc32: EB7838FC episode: 679 release_group: ISLAND screen_size: 720p title: One Piece subtitle_language: fr - video_profile: 8bit + color_depth: 8-bit version: 1 ? '[ISLAND]One_Piece_679_[VOSTFR]_[8bit]_[720p]_[EB7838FC].mp4' -: options: -E - crc32: EB7838FC +: crc32: EB7838FC episode: 679 release_group: ISLAND screen_size: 720p title: One Piece subtitle_language: fr - video_profile: 8bit + color_depth: 8-bit ? '[Kaerizaki-Fansub]_One_Piece_679_[VOSTFR][HD_1280x720].mp4' -: options: -E - episode: 679 +: episode: 679 other: HD release_group: Kaerizaki-Fansub screen_size: 720p @@ -1470,19 +1429,15 @@ subtitle_language: fr ? '[Kaerizaki-Fansub]_One_Piece_679_[VOSTFR][FANSUB][HD_1280x720].mp4' -: options: -E - episode: 679 - other: - - Fansub - - HD +: episode: 679 + other: [Fan Subtitled, HD] release_group: Kaerizaki-Fansub screen_size: 720p title: One Piece subtitle_language: fr ? '[Kaerizaki-Fansub]_One_Piece_681_[VOSTFR][HD_1280x720]_V2.mp4' -: options: -E - episode: 681 +: episode: 681 other: HD release_group: Kaerizaki-Fansub screen_size: 720p @@ -1491,8 +1446,7 @@ version: 2 ? '[Kaerizaki-Fansub] High School DxD New 04 VOSTFR HD (1280x720) V2.mp4' -: options: -E - episode: 4 +: episode: 4 other: HD release_group: Kaerizaki-Fansub screen_size: 720p @@ -1501,11 +1455,9 @@ version: 2 ? '[Kaerizaki-Fansub] One Piece 603 VOSTFR PS VITA (960x544) V2.mp4' -: options: -E - episode: 603 - release_group: - - Kaerizaki-Fansub - - PS VITA +: episode: 603 + release_group: Kaerizaki-Fansub + other: PS Vita screen_size: 960x544 title: One Piece subtitle_language: fr @@ -1537,13 +1489,12 @@ release_group: Stratos-Subs screen_size: 720p title: Infinite Stratos - video_codec: h264 + video_codec: H.264 # [ShinBunBu-Subs] Bleach - 02-03 (CX 1280x720 x264 AAC) ? '[SGKK] Bleach 312v1 [720p/MKV]' -: options: -E # guessit 1.x for episode only when version is guessed, but it's doesn't make it consistent. - episode: 312 +: episode: 312 release_group: SGKK screen_size: 720p title: Bleach @@ -1555,7 +1506,7 @@ release_group: Ayako screen_size: 720p title: Infinite Stratos - video_codec: h264 + video_codec: H.264 ? '[Ayako] Infinite Stratos - IS - 07v2 [H264][720p][44419534]' : crc32: '44419534' @@ -1563,7 +1514,7 @@ release_group: Ayako screen_size: 720p title: Infinite Stratos - video_codec: h264 + video_codec: H.264 version: 2 ? '[Ayako-Shikkaku] Oniichan no Koto Nanka Zenzen Suki Janain Dakara ne - 10 [LQ][h264][720p] [8853B21C]' @@ -1572,18 +1523,15 @@ release_group: Ayako-Shikkaku screen_size: 720p title: Oniichan no Koto Nanka Zenzen Suki Janain Dakara ne - video_codec: h264 + video_codec: H.264 -# TODO: Add support for absolute episodes ? Bleach - s16e03-04 - 313-314 -? Bleach.s16e03-04.313-314 -? Bleach.s16e03-04.313-314 -? Bleach - s16e03-04 - 313-314 -? Bleach.s16e03-04.313-314 +? Bleach.s16e03-04.313-314-GROUP ? Bleach s16e03e04 313-314 -: episode: [3, 4] +: title: Bleach season: 16 - title: Bleach + episode: [3, 4] + absolute_episode: [313, 314] ? Bleach - 313-314 : options: -E @@ -1596,7 +1544,7 @@ release_group: ShinBunBu-Subs screen_size: 720p title: Bleach - video_codec: h264 + video_codec: H.264 ? 003. Show Name - Ep Name.avi : episode: 3 @@ -1620,23 +1568,23 @@ ? Project.Runway.S14E00.and.S14E01.(Eng.Subs).SDTV.x264-[2Maverick].mp4 : episode: [0, 1] - format: TV + source: TV release_group: 2Maverick season: 14 title: Project Runway subtitle_language: en - video_codec: h264 + video_codec: H.264 ? '[Hatsuyuki-Kaitou]_Fairy_Tail_2_-_16-20_[720p][10bit].torrent' : episode: [16, 17, 18, 19, 20] release_group: Hatsuyuki-Kaitou screen_size: 720p title: Fairy Tail 2 - video_profile: 10bit + color_depth: 10-bit ? '[Hatsuyuki-Kaitou]_Fairy_Tail_2_-_16-20_(191-195)_[720p][10bit].torrent' -: options: -E - episode: [16, 17, 18, 19, 20, 191, 192, 193, 194, 195] +: episode: [16, 17, 18, 19, 20] + absolute_episode: [191, 192, 193, 194, 195] release_group: Hatsuyuki-Kaitou screen_size: 720p title: Fairy Tail 2 @@ -1650,15 +1598,15 @@ ? The.Good.Wife.S06E01.E10.720p.WEB-DL.DD5.1.H.264-CtrlHD/The.Good.Wife.S06E09.Trust.Issues.720p.WEB-DL.DD5.1.H.264-CtrlHD.mkv : audio_channels: '5.1' - audio_codec: DolbyDigital + audio_codec: Dolby Digital episode: 9 - format: WEB-DL + source: Web release_group: CtrlHD screen_size: 720p season: 6 title: The Good Wife episode_title: Trust Issues - video_codec: h264 + video_codec: H.264 ? Fear the Walking Dead - 01x02 - So Close, Yet So Far.REPACK-KILLERS.French.C.updated.Addic7ed.com.mkv : episode: 2 @@ -1680,16 +1628,15 @@ ? /av/unsorted/The.Daily.Show.2015.07.22.Jake.Gyllenhaal.720p.HDTV.x264-BATV.mkv : date: 2015-07-22 - format: HDTV + source: HDTV release_group: BATV screen_size: 720p title: The Daily Show episode_title: Jake Gyllenhaal - video_codec: h264 + video_codec: H.264 ? "[7.1.7.8.5] Foo Bar - 11 (H.264) [5235532D].mkv" -: options: -E - episode: 11 +: episode: 11 ? my 720p show S01E02 : options: -T "my 720p show" @@ -1720,72 +1667,71 @@ screen_size: 720p season: 1 title: Foo's & Bars - video_codec: XviD + video_codec: Xvid year: 2009 ? Date.Series.10-11-2008.XViD : date: 2008-11-10 - title: Date - video_codec: XviD + title: Date Series + video_codec: Xvid ? Scrubs/SEASON-06/Scrubs.S06E09.My.Perspective.DVDRip.XviD-WAT/scrubs.s06e09.dvdrip.xvid-wat.avi : container: avi episode: 9 episode_title: My Perspective - format: DVD - mimetype: video/x-msvideo + source: DVD + other: Rip release_group: WAT season: 6 title: Scrubs - video_codec: XviD + video_codec: Xvid ? '[PuyaSubs!] Digimon Adventure tri - 01 [720p][F9967949].mkv' : container: mkv crc32: F9967949 episode: 1 - mimetype: video/x-matroska release_group: PuyaSubs! screen_size: 720p title: Digimon Adventure tri ? Sherlock.S01.720p.BluRay.x264-AVCHD -: format: BluRay +: source: Blu-ray screen_size: 720p season: 1 title: Sherlock - video_codec: h264 + video_codec: H.264 ? Running.Wild.With.Bear.Grylls.S02E07.Michael.B.Jordan.PROPER.HDTV.x264-W4F.avi : container: avi episode: 7 episode_title: Michael B Jordan - format: HDTV - mimetype: video/x-msvideo + source: HDTV other: Proper proper_count: 1 release_group: W4F season: 2 title: Running Wild With Bear Grylls - video_codec: h264 + video_codec: H.264 ? Homeland.S05E11.Our.Man.in.Damascus.German.Sub.720p.HDTV.x264.iNTERNAL-BaCKToRG : episode: 11 episode_title: Our Man in Damascus - format: HDTV - release_group: iNTERNAL-BaCKToRG + source: HDTV + other: Internal + release_group: BaCKToRG screen_size: 720p season: 5 subtitle_language: de title: Homeland type: episode - video_codec: h264 + video_codec: H.264 ? Breaking.Bad.S01E01.2008.BluRay.VC1.1080P.5.1.WMV-NOVO : title: Breaking Bad season: 1 episode: 1 year: 2008 - format: BluRay + source: Blu-ray screen_size: 1080p audio_channels: '5.1' container: WMV @@ -1796,8 +1742,8 @@ : title: Cosmos A Space Time Odyssey season: 1 episode: 2 - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 other: Proper proper_count: 1 release_group: LOL @@ -1807,10 +1753,10 @@ : title: Fear The Walking Dead season: 2 episode: 1 - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 audio_codec: AAC - container: MP4 + container: mp4 release_group: k3n type: episode @@ -1820,8 +1766,8 @@ episode: 1 episode_details: Pilot episode_title: Pilot - format: DVD - video_codec: h264 + source: DVD + video_codec: H.264 other: [Screener, Preair] release_group: NoGRP type: episode @@ -1830,8 +1776,8 @@ : title: Once Upon a Time season: 5 episode: 19 - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 other: Proper proper_count: 1 release_group: LOL[ettv] @@ -1841,49 +1787,49 @@ : title: Show Name season: 1 episode: 3 - format: WEB-DL - video_codec: h264 + source: Web + video_codec: H.264 language: hu release_group: nIk type: episode ? Game.of.Thrones.S6.Ep5.X265.Dolby.2.0.KTM3.mp4 : audio_channels: '2.0' - audio_codec: DolbyDigital + audio_codec: Dolby Digital container: mp4 episode: 5 release_group: KTM3 season: 6 title: Game of Thrones type: episode - video_codec: h265 + video_codec: H.265 ? Fargo.-.Season.1.-.720p.BluRay.-.x264.-.ShAaNiG -: format: BluRay +: source: Blu-ray release_group: ShAaNiG screen_size: 720p season: 1 title: Fargo type: episode - video_codec: h264 + video_codec: H.264 ? Show.Name.S02E02.Episode.Title.1080p.WEB-DL.x264.5.1Ch.-.Group : audio_channels: '5.1' episode: 2 episode_title: Episode Title - format: WEB-DL + source: Web release_group: Group screen_size: 1080p season: 2 title: Show Name type: episode - video_codec: h264 + video_codec: H.264 ? Breaking.Bad.S01E01.2008.BluRay.VC1.1080P.5.1.WMV-NOVO : audio_channels: '5.1' - container: WMV + container: wmv episode: 1 - format: BluRay + source: Blu-ray release_group: NOVO screen_size: 1080p season: 1 @@ -1893,20 +1839,20 @@ ? Cosmos.A.Space.Time.Odyssey.S01E02.HDTV.x264.PROPER-LOL : episode: 2 - format: HDTV + source: HDTV other: Proper proper_count: 1 release_group: LOL season: 1 title: Cosmos A Space Time Odyssey type: episode - video_codec: h264 + video_codec: H.264 ? Elementary.S01E01.Pilot.DVDSCR.x264.PREAiR-NoGRP : episode: 1 episode_details: Pilot episode_title: Pilot - format: DVD + source: DVD other: - Screener - Preair @@ -1914,27 +1860,24 @@ season: 1 title: Elementary type: episode - video_codec: h264 + video_codec: H.264 ? Fear.The.Walking.Dead.S02E01.HDTV.x264.AAC.MP4-k3n.mp4 : audio_codec: AAC - container: - - MP4 - - mp4 + container: mp4 episode: 1 - format: HDTV - mimetype: video/mp4 + source: HDTV release_group: k3n season: 2 title: Fear The Walking Dead type: episode - video_codec: h264 + video_codec: H.264 ? Game.of.Thrones.S03.1080p.BluRay.DTS-HD.MA.5.1.AVC.REMUX-FraMeSToR : audio_channels: '5.1' - audio_codec: DTS - audio_profile: HDMA - format: BluRay + audio_codec: DTS-HD + audio_profile: Master Audio + source: Blu-ray other: Remux release_group: FraMeSToR screen_size: 1080p @@ -1944,16 +1887,16 @@ ? Show.Name.S01E02.HDTV.x264.NL-subs-ABC : episode: 2 - format: HDTV + source: HDTV release_group: ABC season: 1 subtitle_language: nl title: Show Name type: episode - video_codec: h264 + video_codec: H.264 ? Friends.S01-S10.COMPLETE.720p.BluRay.x264-PtM -: format: BluRay +: source: Blu-ray other: Complete release_group: PtM screen_size: 720p @@ -1970,44 +1913,44 @@ - 10 title: Friends type: episode - video_codec: h264 + video_codec: H.264 ? Duck.Dynasty.S02E07.Streik.German.DOKU.DL.WS.DVDRiP.x264-CDP : episode: 7 - episode_title: Streik German DOKU - format: DVD + episode_title: Streik German + source: DVD language: mul - other: WideScreen + other: [Documentary, Widescreen, Rip] release_group: CDP season: 2 title: Duck Dynasty type: episode - video_codec: h264 + video_codec: H.264 ? Family.Guy.S13E14.JOLO.German.AC3D.DL.720p.WebHD.x264-CDD -: audio_codec: AC3 +: audio_codec: Dolby Digital episode: 14 episode_title: JOLO German - format: WEB-DL + source: Web language: mul release_group: CDD screen_size: 720p season: 13 title: Family Guy type: episode - video_codec: h264 + video_codec: H.264 ? How.I.Met.Your.Mother.COMPLETE.SERIES.DVDRip.XviD-AR : options: -L en -C us - format: DVD - other: Complete + source: DVD + other: [Complete, Rip] release_group: AR title: How I Met Your Mother - type: movie - video_codec: XviD + type: movie # Should be episode + video_codec: Xvid ? Show Name The Complete Seasons 1 to 5 720p BluRay x265 HEVC-SUJAIDR[UTR] -: format: BluRay +: source: Blu-ray other: Complete release_group: SUJAIDR[UTR] screen_size: 720p @@ -2019,12 +1962,12 @@ - 5 title: Show Name type: episode - video_codec: h265 + video_codec: H.265 ? Fear.the.Walking.Dead.-.Season.2.epi.02.XviD.Eng.Ac3-5.1.sub.ita.eng.iCV-MIRCrew : options: -t episode audio_channels: '5.1' - audio_codec: AC3 + audio_codec: Dolby Digital episode: 2 episode_title: epi language: en @@ -2033,11 +1976,11 @@ subtitle_language: it title: Fear the Walking Dead type: episode - video_codec: XviD + video_codec: Xvid ? Game.Of.Thrones.S06E04.720p.PROPER.HDTV.x264-HDD : episode: 4 - format: HDTV + source: HDTV other: Proper proper_count: 1 release_group: HDD @@ -2045,4 +1988,2540 @@ season: 6 title: Game Of Thrones type: episode - video_codec: h264 \ No newline at end of file + video_codec: H.264 + +? Marvels.Daredevil.S02E04.WEBRip.x264-NF69.mkv +: container: mkv + episode: 4 + source: Web + other: Rip + release_group: NF69 + season: 2 + title: Marvels Daredevil + type: episode + video_codec: H.264 + +? The.Walking.Dead.S06E01.FRENCH.1080p.WEB-DL.DD5.1.HEVC.x265-GOLF68 +: audio_channels: '5.1' + audio_codec: Dolby Digital + episode: 1 + source: Web + language: fr + release_group: GOLF68 + screen_size: 1080p + season: 6 + title: The Walking Dead + type: episode + video_codec: H.265 + +? American.Crime.S01E03.FASTSUB.VOSTFR.720p.HDTV.x264-F4ST +: episode: 3 + source: HDTV + other: Fast Subtitled + release_group: F4ST + screen_size: 720p + season: 1 + subtitle_language: fr + title: American Crime + type: episode + video_codec: H.264 + +? Gotham.S02E12.FASTSUB.VOSTFR.HDTV.X264-F4ST3R +: episode: 12 + source: HDTV + other: Fast Subtitled + release_group: F4ST3R + season: 2 + subtitle_language: fr + title: Gotham + type: episode + video_codec: H.264 + +# WEBRip + LD +? Australian.Story.2016.05.23.Into.The.Fog.of.War.Part.1.360p.LDTV.WEBRIP.[MPup] +: title: Australian Story + date: 2016-05-23 + episode_title: Into The Fog of War + part: 1 + screen_size: 360p + other: [Low Definition, Rip] + source: Web + release_group: MPup + type: episode + +# AHDTV +? Show.Name.S04E06.FRENCH.AHDTV.XviD +: title: Show Name + season: 4 + episode: 6 + language: fr + source: Analog HDTV + video_codec: Xvid + type: episode + +# WEBDLRip +? Show.Name.s06e14.WEBDLRip.-qqss44.avi +: title: Show Name + season: 6 + episode: 14 + source: Web + other: Rip + release_group: qqss44 + container: avi + type: episode + +# WEBCap +? Steven.Universe.S03E06.Steven.Floats.720p.WEBCap.x264-SRS +: title: Steven Universe + season: 3 + episode: 6 + episode_title: Steven Floats + screen_size: 720p + source: Web + other: Rip + video_codec: H.264 + release_group: SRS + type: episode + +# DSR +? Show.Name.S05E09.Some.Episode.Title.WS.DSR.x264-[NY2] +: title: Show Name + season: 5 + episode: 9 + episode_title: Some Episode Title + other: Widescreen + source: Satellite + video_codec: H.264 + release_group: NY2 + type: episode + +# DSRip +? Squidbillies.S04E05.WS.DSRip.XviD-aAF +: title: Squidbillies + season: 4 + episode: 5 + other: [Widescreen, Rip] + source: Satellite + video_codec: Xvid + release_group: aAF + type: episode + + +? /series/The.B*.B*.T*.S10E01.1080p.HDTV.X264-DIMENSION[rarbg]/The.B*.B*.T*.S10E01.1080p.HDTV.X264-DIMENSION.mkv +: container: mkv + episode: 1 + source: HDTV + release_group: DIMENSION + screen_size: 1080p + season: 10 + title: The B B T + type: episode + video_codec: H.264 + +? '[Y-F] Very long Show Name Here - 03 Vostfr HD 8bits' +: release_group: Y-F + title: Very long Show Name Here + episode: 3 + subtitle_language: fr + other: HD + color_depth: 8-bit + type: episode + +? '[.www.site.com.].-.Snooze.and.Go.Sleep.S03E02.1080p.HEVC.x265-MeGusta' +: episode: 2 + release_group: MeGusta + screen_size: 1080p + season: 3 + title: Snooze and Go Sleep + type: episode + video_codec: H.265 + website: www.site.com + +? Show.Name.S01.720p.HDTV.DD5.1.x264-Group/show.name.0106.720p-group.mkv +: title: Show Name + season: 1 + screen_size: 720p + source: HDTV + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: Group + episode: 6 + container: mkv + type: episode + + +? Coupling Season 1 - 4 Complete DVDRip/Coupling Season 4/Coupling - (4x03) - Bed Time.mkv +: title: Coupling + other: [Complete, Rip] + source: DVD + season: 4 + episode: 3 + episode_title: Bed Time + container: mkv + type: episode + + +? Vice.News.Tonight.2016.10.10.1080p.HBO.WEBRip.AAC2.0.H.264-monkee +: title: Vice News Tonight + date: 2016-10-10 + screen_size: 1080p + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +? frasier.s8e6-768660.srt +: container: srt + episode: 6 + episode_title: '768660' + season: 8 + title: frasier + type: episode + +? Show.Name.S03E15.480p.177mb.Proper.HDTV.x264 +: title: Show Name + season: 3 + episode: 15 + screen_size: 480p + size: 177MB + other: Proper + proper_count: 1 + source: HDTV + video_codec: H.264 + type: episode + +? Show.Name.S03E15.480p.4.8GB.Proper.HDTV.x264 +: title: Show Name + season: 3 + episode: 15 + screen_size: 480p + size: 4.8GB + other: Proper + proper_count: 1 + source: HDTV + video_codec: H.264 + type: episode + +? Show.Name.S03.1.1TB.Proper.HDTV.x264 +: title: Show Name + season: 3 + size: 1.1TB + other: Proper + proper_count: 1 + source: HDTV + video_codec: H.264 + type: episode + +? Some.Show.S02E14.1080p.HDTV.X264-reenc.GROUP +? Some.Show.S02E14.1080p.HDTV.X264-re-enc.GROUP +? Some.Show.S02E14.1080p.HDTV.X264-re-encoded.GROUP +? Some.Show.S02E14.1080p.HDTV.X264-reencoded.GROUP +: title: Some Show + season: 2 + episode: 14 + screen_size: 1080p + source: HDTV + video_codec: H.264 + other: Reencoded + release_group: GROUP + type: episode + +# DDP is DD+ +? Show.Name.2016.S01E01.2160p.AMZN.WEBRip.DDP5.1.x264-Group +: title: Show Name + year: 2016 + season: 1 + episode: 1 + screen_size: 2160p + streaming_service: Amazon Prime + source: Web + other: Rip + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + release_group: Group + type: episode + +? Show Name S02e19 [Mux - H264 - Ita Aac] DLMux by UBi +: title: Show Name + season: 2 + episode: 19 + video_codec: H.264 + language: it + audio_codec: AAC + source: Web + other: Mux + release_group: UBi + type: episode + +? Show Name S01e10[Mux - 1080p - H264 - Ita Eng Ac3 - Sub Ita Eng]DLMux By GiuseppeTnT Littlelinx +: title: Show Name + season: 1 + episode: 10 + screen_size: 1080p + video_codec: H.264 + language: [it, en] + source: Web + other: Mux + audio_codec: Dolby Digital + subtitle_language: [it, en] + release_group: GiuseppeTnT Littlelinx + type: episode + +? Show Name S04e07-08 [H264 - Ita Aac] HDTVMux by Group +: title: Show Name + season: 4 + episode: [7, 8] + video_codec: H.264 + language: it + audio_codec: AAC + source: HDTV + other: Mux + release_group: Group + type: episode + +? Show Name 3x18 Un Tuffo Nel Passato ITA HDTVMux x264 Group +: title: Show Name + season: 3 + episode: 18 + episode_title: Un Tuffo Nel Passato + language: it + source: HDTV + other: Mux + video_codec: H.264 + release_group: Group + type: episode + +? Show.Name.S03.1080p.BlurayMUX.AVC.DTS-HD.MA +: title: Show Name + season: 3 + screen_size: 1080p + source: Blu-ray + other: Mux + video_codec: H.264 + audio_codec: DTS-HD + audio_profile: Master Audio + type: episode + +? Show.Name.-.07.(2016).[RH].[English.Dubbed][WEBRip]..[HD.1080p] +: options: -t episode + episode: 7 + source: Web + other: Rip + language: en + other: [HD, Rip] + screen_size: 1080p + title: Show Name + type: episode + year: 2016 + +? Show.Name.-.476-479.(2007).[HorribleSubs][WEBRip]..[HD.720p] +: options: -t episode + episode: + - 476 + - 477 + - 478 + - 479 + source: Web + other: [Rip, HD] + release_group: HorribleSubs + screen_size: 720p + title: Show Name + type: episode + year: 2007 + +? /11.22.63/Season 1/11.22.63.106.hdtv-abc +: options: -T 11.22.63 + title: 11.22.63 + season: 1 + episode: 6 + source: HDTV + release_group: abc + type: episode + +? Proof.2015.S01E10.1080p.WEB-DL.DD5.1.H.264-KINGS.mkv +: title: Proof + season: 1 + episode: 10 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: KINGS + container: mkv + type: episode + +# Hardcoded subtitles +? Show.Name.S06E16.HC.SWESUB.HDTV.x264 +: title: Show Name + season: 6 + episode: 16 + other: Hardcoded Subtitles + source: HDTV + video_codec: H.264 + subtitle_language: sv + type: episode + +? From [ WWW.TORRENTING.COM ] - White.Rabbit.Project.S01E08.1080p.NF.WEBRip.DD5.1.x264-ViSUM/White.Rabbit.Project.S01E08.1080p.NF.WEBRip.DD5.1.x264-ViSUM.mkv +: title: White Rabbit Project + website: WWW.TORRENTING.COM + season: 1 + episode: 8 + screen_size: 1080p + streaming_service: Netflix + source: Web + other: Rip + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: ViSUM + container: mkv + type: episode + +? /tv/Daniel Tiger's Neighborhood/S02E06 - Playtime Is Different.mp4 +: season: 2 + episode: 6 + title: Daniel Tiger's Neighborhood + episode_title: Playtime Is Different + container: mp4 + type: episode + +? Zoo.S02E05.1080p.WEB-DL.DD5.1.H.264.HKD/160725_02.mkv +: title: Zoo + season: 2 + episode: 5 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: HKD + container: mkv + type: episode + +? We.Bare.Bears.S01E14.Brother.Up.1080p.WEB-DL.AAC2.0.H.264-TVSmash/mxNMuJWeO7PUWCMEwqKSsS6D8Vs9S6V3PHD.mkv +: title: We Bare Bears + season: 1 + episode: 14 + episode_title: Brother Up + screen_size: 1080p + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: TVSmash + container: mkv + type: episode + +? Beyond.S01E02.Tempus.Fugit.720p.FREE.WEBRip.AAC2.0.x264-BTW/gNWDXow11s7E0X7GTDrZ.mkv +: title: Beyond + season: 1 + episode: 2 + episode_title: Tempus Fugit + screen_size: 720p + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTW + container: mkv + type: episode + +? Bones.S12E02.The.Brain.In.The.Bot.1080p.WEB-DL.DD5.1.H.264-R2D2/161219_06.mkv +: title: Bones + season: 12 + episode: 2 + episode_title: The Brain In The Bot + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: R2D2 + container: mkv + type: episode + +? The.Messengers.2015.S01E07.1080p.WEB-DL.DD5.1.H264.Nlsubs-Q/QoQ-sbuSLN.462.H.1.5DD.LD-BEW.p0801.70E10S.5102.sregnesseM.ehT.mkv +: title: The Messengers + year: 2015 + season: 1 + episode: 7 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + subtitle_language: nl + release_group: Q + container: mkv + type: episode + +? /Finding.Carter.S02E01.Love.the.Way.You.Lie.1080p.WEB-DL.AAC2.0.H.264-NL/LN-462.H.0.2CAA.LD-BEW.p0801.eiL.uoY.yaW.eht.evoL.10E20S.retraC.gnidniF.mkv +: title: Finding Carter + season: 2 + episode: 1 + episode_title: Love the Way You Lie + screen_size: 1080p + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: NL + container: mkv + type: episode + +? Mr.Robot.S02E12.1080p.WEB-DL.DD5.1-NL.Subs-Het.Robot.Team.OYM/sbuS LN-1.5DD LD-BEW p0801 21E20S toboR .rM.mkv +: title: Mr Robot + season: 2 + episode: 12 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + release_group: Het.Robot.Team.OYM + type: episode + +? Show.Name.-.Temporada.1.720p.HDTV.x264[Cap.102]SPANISH.AUDIO-NEWPCT +? /Show Name/Season 01/Show.Name.-.Temporada.1.720p.HDTV.x264[Cap.102]SPANISH.AUDIO-NEWPCT +? /Show Name/Temporada 01/Show.Name.-.Temporada.1.720p.HDTV.x264[Cap.102]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 1 + episode: 2 + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct +? Show Name - Temporada 4 [HDTV][Cap.408][Espanol Castellano] +? Show Name - Temporada 4 [HDTV][Cap.408][Español Castellano] +: title: Show Name + season: 4 + episode: 8 + source: HDTV + language: ca + type: episode + +# newpct +? -Show Name - Temporada 4 [HDTV][Cap.408][Espanol Castellano] +? -Show Name - Temporada 4 [HDTV][Cap.408][Español Castellano] +: release_group: Castellano + +# newpct +? Show.Name.-.Temporada1.[HDTV][Cap.105][Español.Castellano] +: title: Show Name + source: HDTV + season: 1 + episode: 5 + language: ca + type: episode + +# newpct +? Show.Name.-.Temporada1.[HDTV][Cap.105][Español] +: title: Show Name + source: HDTV + season: 1 + episode: 5 + language: es + type: episode + +# newpct - season and episode with range: +? Show.Name.-.Temporada.1.720p.HDTV.x264[Cap.102_104]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 1 + episode: [2, 3, 4] + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct - season and episode (2 digit season) +? Show.Name.-.Temporada.15.720p.HDTV.x264[Cap.1503]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 15 + episode: 3 + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct - season and episode (2 digit season with range) +? Show.Name.-.Temporada.15.720p.HDTV.x264[Cap.1503_1506]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 15 + episode: [3, 4, 5, 6] + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct - season and episode: +? Show.Name.-.Temp.1.720p.HDTV.x264[Cap.102]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 1 + episode: 2 + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct - season and episode: +? Show.Name.-.Tem.1.720p.HDTV.x264[Cap.102]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 1 + episode: 2 + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + type: episode + +# newpct - season and episode: +? Show.Name.-.Tem.1.720p.HDTV.x264[Cap.112_114.Final]SPANISH.AUDIO-NEWPCT +: title: Show Name + season: 1 + episode: [12, 13, 14] + screen_size: 720p + source: HDTV + video_codec: H.264 + language: es + release_group: NEWPCT + episode_details: Final + type: episode + +? Mastercook Italia - Stagione 6 (2016) 720p ep13 spyro.mkv +: title: Mastercook Italia + season: 6 + episode: 13 + year: 2016 + screen_size: 720p + episode_title: spyro + container: mkv + type: episode + +? Mastercook Italia - Stagione 6 (2016) 720p Episodio 13 spyro.mkv +: title: Mastercook Italia + season: 6 + year: 2016 + screen_size: 720p + episode: 13 + episode_title: spyro + container: mkv + type: episode + +# Italian releases +? Show Name 3x18 Un Tuffo Nel Passato ITA HDTVMux x264 NovaRip +: title: Show Name + season: 3 + episode: 18 + episode_title: Un Tuffo Nel Passato + language: it + source: HDTV + other: Mux + video_codec: H.264 + release_group: NovaRip + type: episode + +# Italian releases +? Show Name 3x18 Un Tuffo Nel Passato ITA HDTVMux x264 NovaRip +: title: Show Name + season: 3 + episode: 18 + episode_title: Un Tuffo Nel Passato + language: it + source: HDTV + other: Mux + video_codec: H.264 + release_group: NovaRip + type: episode + +# Subbed: No language hint +? Show.Name.S06E03.1080p.HDTV.Legendado +: subtitle_language: und + +# Subbed: No language hint +? Show.Name.S01E09.Subbed.1080p.BluRay.x264-RRH +: title: Show Name + season: 1 + episode: 9 + subtitle_language: und + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: RRH + type: episode + +# Legendado PT-BR +? Show.Name.S06E05.1080p.WEBRip.Legendado.PT-BR +? Show.Name.S06E05.1080p.WEBRip.Legendas.PT-BR +? Show.Name.S06E05.1080p.WEBRip.Legenda.PT-BR +: title: Show Name + season: 6 + episode: 5 + screen_size: 1080p + source: Web + other: Rip + subtitle_language: pt-BR + type: episode + +? Show.Name.S01E07.Super, Title.WEB-DL 720p.br.srt +: title: Show Name + season: 1 + episode: 7 + episode_title: Super, Title + source: Web + screen_size: 720p + subtitle_language: pt-BR + container: srt + type: episode + +? -Show.Name.S01E07.Super, Title.WEB-DL 720p.br.srt +: language: pt-BR + +# Legendado PT +? Show.Name.S06E05.1080p.WEBRip.Legendado.PT +: title: Show Name + season: 6 + episode: 5 + screen_size: 1080p + source: Web + other: Rip + subtitle_language: pt + type: episode + +? Show.Name.S05E01.SPANISH.SUBBED.720p.HDTV.x264-sPHD +: title: Show Name + season: 5 + episode: 1 + subtitle_language: spa + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: sPHD + type: episode + +? Show.Name.S01E01.German.Subbed.HDTV.XviD-ASAP +: title: Show Name + season: 1 + episode: 1 + subtitle_language: deu + source: HDTV + video_codec: Xvid + release_group: ASAP + type: episode + +? Show.Name.S04E21.Aint.Nothing.Like.the.Real.Thing.German.Custom.Subbed.720p.HDTV.x264.iNTERNAL-BaCKToRG +: title: Show Name + season: 4 + episode: 21 + episode_title: Aint Nothing Like the Real Thing + subtitle_language: deu + screen_size: 720p + source: HDTV + video_codec: H.264 + type: episode + +? Show.Name.S01.Season.Complet.WEBRiP.Ro.Subbed.TM +: title: Show Name + season: 1 + other: [Complete, Rip] + source: Web + subtitle_language: ro + type: episode + +? Show.Name.(2013).Season.3.-.Eng.Soft.Subtitles.720p.WEBRip.x264.[MKV,AC3,5.1].Ehhhh +: title: Show Name + year: 2013 + season: 3 + subtitle_language: en + screen_size: 720p + source: Web + other: Rip + video_codec: H.264 + container: mkv + audio_codec: Dolby Digital + audio_channels: '5.1' + release_group: Ehhhh + type: episode + +# Dublado +? Show.Name.S02E03.720p.HDTV.x264-Belex.-.Dual.Audio.-.Dublado +: title: Show Name + season: 2 + episode: 3 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: Belex + other: Dual Audio + language: und + type: episode + +? Show.Name.S06E10.1080p.WEB-DL.DUAL.[Dublado].RK +: title: Show Name + season: 6 + episode: 10 + screen_size: 1080p + source: Web + other: Dual Audio + language: und + release_group: RK + type: episode + +? Show.Name.S06E12.720p.WEB-DL.Dual.Audio.Dublado +: title: Show Name + season: 6 + episode: 12 + screen_size: 720p + source: Web + other: Dual Audio + language: und + type: episode + +? Show.Name.S05E07.720p.DUBLADO.HDTV.x264-0SEC-pia.mkv +: title: Show Name + season: 5 + episode: 7 + screen_size: 720p + language: und + source: HDTV + video_codec: H.264 + release_group: 0SEC-pia + container: mkv + type: episode + +? Show.Name.S02E07.Shiva.AC3.Dubbed.WEBRip.x264 +: title: Show Name + season: 2 + episode: 7 + episode_title: Shiva + audio_codec: Dolby Digital + language: und + source: Web + other: Rip + video_codec: H.264 + type: episode + +# Legendas +? Show.Name.S05.1080p.BluRay.x264-Belex.-.Dual.Audio.+.Legendas +: title: Show Name + season: 5 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: Belex + other: Dual Audio + subtitle_language: und + type: episode + +# Legendas +? Show.Name.S05.1080p.BluRay.x264-Belex.-.Dual.Audio.+.Legendas +: title: Show Name + season: 5 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: Belex + other: Dual Audio + subtitle_language: und + type: episode + +# Subtitulado +? Show.Name.S01E03.HDTV.Subtitulado.Esp.SC +? Show.Name.S01E03.HDTV.Subtitulado.Espanol.SC +? Show.Name.S01E03.HDTV.Subtitulado.Español.SC +: title: Show Name + season: 1 + episode: 3 + source: HDTV + subtitle_language: es + release_group: SC + type: episode + +# Subtitles/Subbed +? Show.Name.S02E08.720p.WEB-DL.Subtitles +? Show.Name.S02E08.Subbed.720p.WEB-DL +: title: Show Name + season: 2 + episode: 8 + screen_size: 720p + source: Web + subtitle_language: und + type: episode + +# Dubbed +? Show.Name.s01e01.german.Dubbed +: title: Show Name + season: 1 + episode: 1 + language: de + type: episode + +? Show.Name.S06E05.Das.Toor.German.AC3.Dubbed.HDTV.German +: title: Show Name + season: 6 + episode: 5 + language: de + audio_codec: Dolby Digital + source: HDTV + type: episode + +? Show.Name.S01E01.Savage.Season.GERMAN.DUBBED.WS.HDTVRip.x264-TVP +: title: Show Name + season: 1 + episode: 1 + episode_title: Savage Season + language: de + other: [Widescreen, Rip] + source: HDTV + video_codec: H.264 + release_group: TVP + type: episode + +# Dubbed +? "[AnimeRG].Show.Name.-.03.[Eng.Dubbed].[720p].[WEB-DL].[JRR]" +: title: Show Name + episode: 3 + language: en + screen_size: 720p + source: Web + release_group: JRR + type: episode + +# Dubbed +? "[RH].Show.Name.-.03.[English.Dubbed].[1080p]" +: title: Show Name + episode: 3 + language: en + screen_size: 1080p + release_group: RH + type: episode + +# Hebsubs +? Show.Name.S05E05.HDTV.XviD-AFG.HebSubs +: title: Show Name + season: 5 + episode: 5 + source: HDTV + video_codec: Xvid + release_group: AFG + subtitle_language: he + type: episode + +? Show Name - S02E31 - Episode 55 (720p.HDTV) +: title: Show Name + season: 2 + episode: 31 + episode_title: Episode 55 + screen_size: 720p + source: HDTV + type: episode + +# Scenario: Removing invalid season and episode matches. Correct episode_title match +? Show.Name.S02E06.eps2.4.m4ster-s1ave.aes.1080p.AMZN.WEBRip.DD5.1.x264-GROUP +: title: Show Name + season: 2 + episode: 6 + episode_title: eps2 4 m4ster-s1ave aes + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + other: Rip + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: GROUP + type: episode + +? Show.Name.S01E05.3xpl0its.wmv.720p.WEBdl.EN-SUB.x264-[MULVAcoded].mkv +: title: Show Name + season: 1 + episode: 5 + episode_title: 3xpl0its + screen_size: 720p + source: Web + subtitle_language: en + video_codec: H.264 + type: episode + +# Regression: S4L release group detected as season 4 +# https://github.com/guessit-io/guessit/issues/352 +? Show Name S01E06 DVD-RIP x264-S4L +: title: Show Name + season: 1 + episode: 6 + source: DVD + video_codec: H.264 + release_group: S4L + type: episode + +# Corner case with only date and 720p +? The.Show.Name.2016.05.18.720.HDTV.x264-GROUP.VTV +: title: The Show Name + date: 2016-05-18 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: GROUP.VTV + type: episode + +# Corner case with only date and 720p +? -The.Show.Name.2016.05.18.720.HDTV.x264-GROUP.VTV +: season: 7 + episode: 20 + +# https://github.com/guessit-io/guessit/issues/308 (conflict with screen size) +? "[SuperGroup].Show.Name.-.06.[720.Hi10p][1F5578AC]" +: title: Show Name + episode: 6 + screen_size: 720p + color_depth: 10-bit + crc32: 1F5578AC + release_group: SuperGroup + type: episode + +# https://github.com/guessit-io/guessit/issues/308 (conflict with screen size) +? "[SuperGroup].Show.Name.-.06.[1080.Hi10p][1F5578AC]" +: title: Show Name + episode: 6 + screen_size: 1080p + color_depth: 10-bit + crc32: 1F5578AC + release_group: SuperGroup + type: episode + +? "[MK-Pn8].Dimension.W.-.05.[720p][Hi10][Dual][TV-Dub][EDA6E7F1]" +: options: -C us -L und + release_group: MK-Pn8 + title: Dimension W + episode: 5 + screen_size: 720p + color_depth: 10-bit + other: Dual Audio + source: TV + language: und + crc32: EDA6E7F1 + type: episode + +? "[Zero-Raws].Show.Name.493-498.&.500-507.(CX.1280x720.VFR.x264.AAC)" +: release_group: Zero-Raws + title: Show Name + episode: [493, 494, 495, 496, 497, 498, 500, 501, 502, 503, 504, 505, 506, 507] + screen_size: 720p + subtitle_language: fr + video_codec: H.264 + audio_codec: AAC + type: episode + +# NetflixUHD +? Show.Name.S01E06.NetflixUHD +: title: Show Name + season: 1 + episode: 6 + streaming_service: Netflix + other: Ultra HD + type: episode + +? Show.Name.S04E13.FINAL.MULTI.DD51.2160p.NetflixUHDRip.x265-TVS +: title: Show Name + season: 4 + episode: 13 + episode_details: Final + language: mul + audio_codec: Dolby Digital + audio_channels: '5.1' + screen_size: 2160p + streaming_service: Netflix + source: Ultra HDTV + other: Rip + video_codec: H.265 + release_group: TVS + type: episode + +? Show.Name.S06E11.Of.Late.I.Think.of.Rosewood.iTunesHD.x264 +: title: Show Name + season: 6 + episode: 11 + episode_title: Of Late I Think of Rosewood + streaming_service: iTunes + other: HD + video_codec: H.264 + type: episode + +? Show.Name.S01.720p.iTunes.h264-Group +: title: Show Name + season: 1 + screen_size: 720p + streaming_service: iTunes + video_codec: H.264 + release_group: Group + type: episode + +? Show.Name.1x01.eps1.0.hellofriend.(HDiTunes.Ac3.Esp).(2015).By.Malaguita.avi +: title: Show Name + season: 1 + episode: 1 + episode_title: eps1 0 hellofriend + other: HD + streaming_service: iTunes + audio_codec: Dolby Digital + language: spa + year: 2015 + container: avi + type: episode + +? "[Hanamaru&LoliHouse] The Dragon Dentist - 01 [WebRip 1920x1080 HEVC-yuv420p10 AAC].mkv" +: release_group: Hanamaru&LoliHouse + title: The Dragon Dentist + episode: 1 + source: Web + other: Rip + screen_size: 1080p + video_codec: H.265 + color_depth: 10-bit + audio_codec: AAC + container: mkv + type: episode + +? Show Name - Season 1 Episode 50 +: title: Show Name + season: 1 + episode: 50 + type: episode + +? Vikings.Seizoen.4.1080p.Web.NLsubs +: title: Vikings + season: 4 + screen_size: 1080p + source: Web + subtitle_language: nl + type: episode + +? Star.Wars.Rebels.S01E01.Spark.of.Rebellion.ALTERNATE.CUT.HDTV.x264-W4F.mp4 +: title: Star Wars Rebels + season: 1 + episode: 1 + episode_title: Spark of Rebellion + edition: Alternative Cut + source: HDTV + video_codec: H.264 + release_group: W4F + container: mp4 + type: episode + +? DCs.Legends.of.Tomorrow.S02E12.HDTV.XviD-FUM +: title: DCs Legends of Tomorrow + season: 2 + episode: 12 + source: HDTV + video_codec: Xvid + release_group: FUM + type: episode + +? DC's Legends of Tomorrow 2016 - S02E02 +: title: DC's Legends of Tomorrow + year: 2016 + season: 2 + episode: 2 + type: episode + +? Broadchurch.S01.DIRFIX.720p.BluRay.x264-SHORTBREHD +: title: Broadchurch + season: 1 + other: Fix + screen_size: 720p + source: Blu-ray + video_codec: H.264 + release_group: SHORTBREHD + -proper_count: 1 + type: episode + +? Simply Red - 2016-07-08 Montreux Jazz Festival 720p +: title: Simply Red + date: 2016-07-08 + episode_title: Montreux Jazz Festival + screen_size: 720p + type: episode + +? Ridiculousness.S07E14.iNTERNAL.HDTV.x264-YesTV +: title: Ridiculousness + season: 7 + episode: 14 + other: Internal + source: HDTV + video_codec: H.264 + release_group: YesTV + type: episode + +? Stephen.Colbert.2016.05.25.James.McAvoy.iNTERNAL.XviD-AFG +: title: Stephen Colbert + date: 2016-05-25 + episode_title: James McAvoy + other: Internal + video_codec: Xvid + release_group: AFG + type: episode + +? The.100.S01E13.iNTERNAL.READNFO.720p.HDTV.x264-2HD +: title: The 100 + season: 1 + episode: 13 + other: [Internal, Read NFO] + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: 2HD + type: episode + +? The.100.S01E13.READ.NFO.720p.HDTV.x264-2HD +: title: The 100 + season: 1 + episode: 13 + other: Read NFO + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: 2HD + type: episode + +? Dr.Ken.S01E21.SAMPLEFIX.720p.HDTV.x264-SVA +: title: Dr Ken + season: 1 + episode: 21 + other: Fix + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: SVA + type: episode + +? Rick and Morty Season 1 [UNCENSORED] [BDRip] [1080p] [HEVC] +: title: Rick and Morty + season: 1 + edition: Uncensored + other: Rip + source: Blu-ray + screen_size: 1080p + video_codec: H.265 + type: episode + +? 12.Monkeys.S01E01.LiMiTED.FRENCH.1080p.WEB-DL.H264-AUTHORiTY +: title: 12 Monkeys + season: 1 + episode: 1 + edition: Limited + language: french + screen_size: 1080p + source: Web + video_codec: H.264 + release_group: AUTHORiTY + type: episode + +? Undateable.2014.S03E05.West.Feed.HDTV.x264-2HD +: title: Undateable + year: 2014 + season: 3 + episode: 5 + other: West Coast Feed + source: HDTV + video_codec: H.264 + release_group: 2HD + type: episode + +? Undateable.2014.S02E07-E08.Live.Episode.West.Coast.Feed.HDTV.x264-2HD +: title: Undateable + year: 2014 + season: 2 + episode: [7, 8] + other: West Coast Feed + source: HDTV + video_codec: H.264 + release_group: 2HD + type: episode + +? Undateable.S03E01-E02.LIVE.EAST.FEED.720p.HDTV.x264-KILLERS +: title: Undateable + season: 3 + episode: [1, 2] + other: East Coast Feed + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: KILLERS + type: episode + +? Undateable.2014.S02E07.Live.Episode.East.Coast.Feed.HDTV.x264-2HD +: title: Undateable + year: 2014 + season: 2 + episode: 7 + other: East Coast Feed + source: HDTV + video_codec: H.264 + release_group: 2HD + type: episode + +? Undateable.2014.S02E07.East.Coast.Feed.720p.WEB-DL.DD5.1.H.264-NTb +: title: Undateable + year: 2014 + season: 2 + episode: 7 + other: East Coast Feed + screen_size: 720p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: NTb + type: episode + +? True Detective S02E04 720p HDTV x264-0SEC [GloDLS].mkv +: title: True Detective + season: 2 + episode: 4 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: 0SEC [GloDLS] + container: mkv + type: episode + +? Anthony.Bourdain.Parts.Unknown.S09E01.Los.Angeles.720p.HDTV.x264-MiNDTHEGAP +: title: Anthony Bourdain Parts Unknown + season: 9 + episode: 1 + episode_title: Los Angeles + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: MiNDTHEGAP + type: episode + +? -feud.s01e05.and.the.winner.is.(the.oscars.of.1963).720p.amzn.webrip.dd5.1.x264-casstudio.mkv +: year: 1963 + +? feud.s01e05.and.the.winner.is.(the.oscars.of.1963).720p.amzn.webrip.dd5.1.x264-casstudio.mkv +: title: feud + season: 1 + episode: 5 + episode_title: and the winner is + screen_size: 720p + streaming_service: Amazon Prime + source: Web + other: Rip + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: casstudio + container: mkv + type: episode + +? Adventure.Time.S08E16.Elements.Part.1.Skyhooks.720p.WEB-DL.AAC2.0.H.264-RTN.mkv +: title: Adventure Time + season: 8 + episode: 16 + episode_title: Elements Part 1 Skyhooks + screen_size: 720p + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: RTN + container: mkv + type: episode + +? D:\TV\SITCOMS (CLASSIC)\That '70s Show\Season 07\That '70s Show - S07E22 - 2000 Light Years from Home.mkv +: title: That '70s Show + season: 7 + episode: 22 + episode_title: 2000 Light Years from Home + container: mkv + type: episode + +? Show.Name.S02E01.Super.Title.720p.WEB-DL.DD5.1.H.264-ABC.nzb +: title: Show Name + season: 2 + episode: 1 + episode_title: Super Title + screen_size: 720p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: ABC + container: nzb + type: episode + +? "[SGKK] Bleach 312v1 [720p/mkv]-Group.mkv" +: title: Bleach + episode: 312 + version: 1 + screen_size: 720p + release_group: Group + container: mkv + type: episode + +? The.Expanse.S02E08.720p.WEBRip.x264.EAC3-KiNGS.mkv +: title: The Expanse + season: 2 + episode: 8 + screen_size: 720p + source: Web + other: Rip + video_codec: H.264 + audio_codec: Dolby Digital Plus + release_group: KiNGS + container: mkv + type: episode + +? Series_name.2005.211.episode.title.avi +: title: Series name + year: 2005 + season: 2 + episode: 11 + episode_title: episode title + container: avi + type: episode + +? the.flash.2014.208.hdtv-lol[ettv].mkv +: title: the flash + year: 2014 + season: 2 + episode: 8 + source: HDTV + release_group: lol[ettv] + container: mkv + type: episode + +? "[Despair-Paradise].Kono.Subarashii.Sekai.ni.Shukufuku.wo!.2.-..09.vostfr.FHD" +: release_group: Despair-Paradise + title: Kono Subarashii Sekai ni Shukufuku wo! 2 + episode: 9 + subtitle_language: fr + other: Full HD + type: episode + +? Whose Line is it anyway/Season 01/Whose.Line.is.it.Anyway.US.S13E01.720p.WEB.x264-TBS.mkv +: title: Whose Line is it Anyway + season: 13 + episode: 1 + country: US + screen_size: 720p + source: Web + video_codec: H.264 + release_group: TBS + container: mkv + type: episode + +? Planet.Earth.II.S01.2160p.UHD.BluRay.HDR.DTS-HD.MA5.1.x265-ULTRAHDCLUB +: title: Planet Earth II + season: 1 + screen_size: 2160p + source: Ultra HD Blu-ray + other: HDR10 + audio_codec: DTS-HD + audio_profile: Master Audio + audio_channels: '5.1' + video_codec: H.265 + release_group: ULTRAHDCLUB + type: episode + +? Reizen.Waes.S03.FLEMISH.1080p.HDTV.MP2.H.264-NOGRP/Reizen.Waes.S03E05.China.PART1.FLEMISH.1080p.HDTV.MP2.H.264-NOGRP.mkv +: title: Reizen Waes + season: 3 + episode: 5 + part: 1 + language: nl-BE + screen_size: 1080p + source: HDTV + video_codec: H.264 + audio_codec: MP2 + release_group: NOGRP + container: mkv + type: episode + +? "/folder/Marvels.Agent.Carter.S02E05.The.Atomic.Job.1080p.WEB-DL.DD5.1.H264-Coo7[rartv]/Marvel's.Agent.Carter.S02E05.The.Atomic.Job.1080p.WEB-DL.DD5.1.H.264-Coo7.mkv" +: title: Marvel's Agent Carter + season: 2 + episode: 5 + episode_title: The Atomic Job + release_group: Coo7 + type: episode + +? My.Name.Is.Earl.S01-S04.DVDRip.XviD-AR +: title: My Name Is Earl + season: [1, 2, 3, 4] + source: DVD + other: Rip + video_codec: Xvid + release_group: AR + type: episode + +? American.Dad.S01E01.Pilot.DVDRip.x264-CS +: title: American Dad + season: 1 + episode: 1 + episode_details: Pilot + source: DVD + other: Rip + video_codec: H.264 + release_group: CS + type: episode + +? Black.Sails.S01E01.HDTV.XviD.HebSubs-DR +: title: Black Sails + season: 1 + episode: 1 + source: HDTV + video_codec: Xvid + subtitle_language: he + release_group: DR + type: episode + +? The.West.Wing.S04E06.Game.On.720p.WEB-DL.AAC2.0.H.264-MC +: title: The West Wing + season: 4 + episode: 6 + episode_title: Game On + screen_size: 720p + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: MC + type: episode + +? 12.Monkeys.S02E05.1080p.WEB-DL.DD5.1.H.264-NA +: title: 12 Monkeys + season: 2 + episode: 5 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: NA + type: episode + +? Fear.the.Walking.Dead.S03E07.1080p.AMZN.WEBRip.DD5.1.x264-VLAD[rarbg]/Fear.the.Walking.Dead.S03E07.1080p.AMZN.WEB-DL.DD+5.1.H.264-VLAD.mkv +: title: Fear the Walking Dead + season: 3 + episode: 7 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + release_group: VLAD + container: mkv + type: episode + +? American.Crime.S01E02.1080p.WEB-DL.DD5.1.H.264-NL +: title: American Crime + season: 1 + episode: 2 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: NL + type: episode + +? Better.Call.Saul.S02.720p.HDTV.x264-TL +: title: Better Call Saul + season: 2 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: TL + type: episode + +? 60.Minutes.2008.12.14.HDTV.XviD-YT +: options: -T '60 Minutes' + title: 60 Minutes + date: 2008-12-14 + source: HDTV + video_codec: Xvid + release_group: YT + type: episode + +? Storm.Chasers.Season.1 +: title: Storm Chasers + season: 1 + type: episode + +? Faking.It.2014.S03E08.720p.HDTV.x264-AVS +: title: Faking It + year: 2014 + season: 3 + episode: 8 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: AVS + type: episode + +? /series/Marvel's Agents of S.H.I.E.L.D/Season 4/Marvels.Agents.of.S.H.I.E.L.D.S04E01.The.Ghost.1080p.WEB-DL.DD5.1.H.264-AG.mkv +: title: Marvels Agents of S.H.I.E.L.D. + season: 4 + episode: 1 + episode_title: The Ghost + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: AG + container: mkv + type: episode + +? "[FASubs & TTF] Inuyasha - 099 [DVD] [B15AA1AC].mkv" +: release_group: FASubs & TTF + title: Inuyasha + episode: 99 + source: DVD + crc32: B15AA1AC + container: mkv + type: episode + +? Show.Name.S01E03.PL.SUBBED.480p.WEBRiP.x264 +: title: Show Name + season: 1 + episode: 3 + subtitle_language: pl + screen_size: 480p + source: Web + other: Rip + video_codec: H.264 + type: episode + +? Show.Name.s10e15(233).480p.BDRip-AVC.Ukr.hurtom +: title: Show Name + season: 10 + episode: 15 + screen_size: 480p + source: Blu-ray + other: Rip + video_codec: H.264 + language: uk + release_group: hurtom + type: episode + +? Goof.Troop.1x24.Waste.Makes.Haste.720p.HDTV.x264.CZ-SDTV +: title: Goof Troop + season: 1 + episode: 24 + episode_title: Waste Makes Haste + screen_size: 720p + source: HDTV + video_codec: H.264 + language: cs + release_group: SDTV + type: episode + +? Marvels.Daredevil.S02E11.German.DL.DUBBED.2160p.WebUHD.x264-UHDTV +: title: Marvels Daredevil + season: 2 + episode: 11 + language: [de, mul] + screen_size: 2160p + source: Web + video_codec: H.264 + release_group: UHDTV + type: episode + +? BBC The Story of China 1 of 6 - Ancestors CC HDTV x264 AC3 2.0 720p mkv +: title: BBC The Story of China + episode: 1 + episode_count: 6 + episode_title: Ancestors + source: HDTV + video_codec: H.264 + audio_codec: Dolby Digital + audio_channels: '2.0' + screen_size: 720p + container: mkv + type: episode + +? Duck.Dynasty.S09E04.Drone.Survivor.720p.AE.WEBRip.AAC2.0.H264-BTW[rartv] +: title: Duck Dynasty + season: 9 + episode: 4 + episode_title: Drone Survivor + screen_size: 720p + streaming_service: A&E + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTW[rartv] + type: episode + +? Mr.Selfridge.S04E03.720p.WEB-DL.AAC2.0.H264-MS[rartv] +: title: Mr Selfridge + season: 4 + episode: 3 + screen_size: 720p + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: MS[rartv] + type: episode + +? Second.Chance.S01E02.One.More.Notch.1080p.WEB-DL.DD5.1.H264-SC[rartv] +: title: Second Chance + season: 1 + episode: 2 + episode_title: One More Notch + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: rartv + type: episode + +? Total.Divas.S05E01.720p.HDTV.AAC2.0.H.264-SC-SDH +: title: Total Divas + season: 5 + episode: 1 + screen_size: 720p + source: HDTV + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + video_profile: Scalable Video Coding + release_group: SDH + type: episode + +? Marvel's Jessica Jones (2015) s01e09 - AKA Sin Bin.mkv +: title: Marvel's Jessica Jones + season: 1 + episode: 9 + episode_title: AKA Sin Bin + container: mkv + type: episode + +? Hotel.Hell.S01E01.720p.DD5.1.448kbps-ALANiS +: title: Hotel Hell + season: 1 + episode: 1 + screen_size: 720p + audio_codec: Dolby Digital + audio_channels: '5.1' + audio_bit_rate: 448Kbps + release_group: ALANiS + type: episode + +? Greys.Anatomy.S07D1.NTSC.DVDR-ToF +: title: Greys Anatomy + season: 7 + disc: 1 + other: NTSC + source: DVD + release_group: ToF + type: episode + +? Greys.Anatomy.S07D1.NTSC.DVDR-ToF +: title: Greys Anatomy + season: 7 + disc: 1 + other: NTSC + source: DVD + release_group: ToF + type: episode + +? Greys.Anatomy.S07D1-3&5.NTSC.DVDR-ToF +: title: Greys Anatomy + season: 7 + disc: [1, 2, 3, 5] + other: NTSC + source: DVD + release_group: ToF + type: episode + +? El.Principe.2014.S01D01.SPANiSH.COMPLETE.BLURAY-COJONUDO +: title: El Principe + year: 2014 + season: 1 + disc: 1 + language: spa + other: Complete + source: Blu-ray + release_group: COJONUDO + type: episode + +? The Simpsons - Season 2 Complete [DVDRIP VP7 KEGGERMAN +: title: The Simpsons + season: 2 + other: [Complete, Rip] + source: DVD + video_codec: VP7 + release_group: KEGGERMAN + type: episode + +? Barney & Friends_ Easy as ABC (Season 9_ Episode 15)_VP8_Vorbis_360p.webm +: title: Barney & Friends Easy as ABC + season: 9 + episode: 15 + video_codec: VP8 + audio_codec: Vorbis + screen_size: 360p + container: webm + type: episode + +? Victoria.S01.1080p.BluRay.HEVC.DTSMA.LPCM.PGS-OZM +: title: Victoria + season: 1 + screen_size: 1080p + source: Blu-ray + video_codec: H.265 + audio_codec: [DTS-HD, LPCM] + audio_profile: Master Audio + # Does it worth to add subtitle_format? Such rare case + # subtitle_format: PGS + # release_group: OZM + type: episode + +? The.Prisoners.S01E03.1080p.DM.AAC2.0.x264-BTN +: title: The Prisoners + season: 1 + episode: 3 + screen_size: 1080p + source: Digital Master + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTN + type: episode + +? Panorama.S2013E25.Broken.by.Battle.1080p.DM.AAC2.0.x264-BTN +: title: Panorama + season: 2013 + episode: 25 + episode_title: Broken by Battle + screen_size: 1080p + source: Digital Master + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTN + type: episode + +? Our.World.S2014E11.Chinas.Model.Army.720p.DM.AAC2.0.x264-BTN +: title: Our World + season: 2014 + episode: 11 + episode_title: Chinas Model Army + screen_size: 720p + source: Digital Master + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTN + type: episode + +? Storyville.S2016E08.My.Nazi.Legacy.1080p.DM.x264-BTN +: title: Storyville + season: 2016 + episode: 8 + episode_title: My Nazi Legacy + screen_size: 1080p + source: Digital Master + video_codec: H.264 + release_group: BTN + type: episode + +? Comedians.in.Cars.Getting.Coffee.S07E01.1080p.DM.FLAC2.0.x264-NTb +: title: Comedians in Cars Getting Coffee + season: 7 + episode: 1 + screen_size: 1080p + source: Digital Master + audio_codec: FLAC + audio_channels: '2.0' + video_codec: H.264 + release_group: NTb + type: episode + +? "[SomeGroup-Fansub]_Show_Name_727_[VOSTFR][HD_1280x720]" +: release_group: SomeGroup-Fansub + title: Show Name + episode: 727 + subtitle_language: fr + other: HD + screen_size: 720p + type: episode + +? "[GROUP]Show_Name_726_[VOSTFR]_[V1]_[8bit]_[720p]_[2F7B3FA2]" +: release_group: GROUP + title: Show Name + episode: 726 + subtitle_language: fr + version: 1 + color_depth: 8-bit + screen_size: 720p + crc32: 2F7B3FA2 + type: episode + +? Show Name 445 VOSTFR par Fansub-Resistance (1280*720) - version MQ +: title: Show Name + episode: 445 + subtitle_language: fr + screen_size: 720p + type: episode + +? Anime Show Episode 159 v2 [VOSTFR][720p][AAC].mp4 +: title: Anime Show + episode: 159 + version: 2 + subtitle_language: fr + screen_size: 720p + audio_codec: AAC + container: mp4 + type: episode + +? "[Group] Anime Super Episode 161 [VOSTFR][720p].mp4" +: release_group: Group + title: Anime Super + episode: 161 + subtitle_language: fr + screen_size: 720p + container: mp4 + type: episode + +? Anime Show Episode 59 v2 [VOSTFR][720p][AAC].mp4 +: title: Anime Show + episode: 59 + version: 2 + subtitle_language: fr + screen_size: 720p + audio_codec: AAC + container: mp4 + type: episode + +? Show.Name.-.476-479.(2007).[HorribleSubs][WEBRip]..[HD.720p] +: title: Show Name + episode: [476, 477, 478, 479] + year: 2007 + release_group: HorribleSubs + source: Web + other: [Rip, HD] + screen_size: 720p + type: episode + +? Show Name - 722 [HD_1280x720].mp4 +: title: Show Name + episode: 722 + other: HD + screen_size: 720p + container: mp4 + type: episode + +? Show!.Name.2.-.10.(2016).[HorribleSubs][WEBRip]..[HD.720p] +: title: Show! Name 2 + episode: 10 + year: 2016 + release_group: HorribleSubs + source: Web + other: [Rip, HD] + screen_size: 720p + type: episode + +? 'C:\folder\[GROUP]_An_Anime_Show_100_-_10_[1080p]_mkv' +: options: -T 'An Anime Show 100' + release_group: GROUP + title: An Anime Show 100 + episode: 10 + screen_size: 1080p + container: mkv + type: episode + +? "[Group].Show.Name!.Super!!.-.05.[720p][AAC].mp4" +: release_group: Group + title: Show Name! Super!! + episode: 5 + screen_size: 720p + audio_codec: AAC + container: mp4 + type: episode + +? "[GROUP].Mobile.Suit.Gundam.Unicorn.RE.0096.-.14.[720p].mkv" +: options: -T 'Mobile Suit Gundam Unicorn RE 0096' + release_group: GROUP + title: Mobile Suit Gundam Unicorn RE 0096 + episode: 14 + screen_size: 720p + container: mkv + type: episode + +? Show.Name.-.Other Name.-.02.(1280x720.HEVC.AAC) +: title: Show Name + alternative_title: Other Name + episode: 2 + screen_size: 720p + video_codec: H.265 + audio_codec: AAC + type: episode + +? "[GroupName].Show.Name.-.02.5.(Special).[BD.1080p]" +: release_group: GroupName + title: Show Name + episode: 2 + episode_details: Special + screen_size: 1080p + source: Blu-ray + type: episode + +? "[Group].Show.Name.2.The.Big.Show.-.11.[1080p]" +: title: Show Name 2 The Big Show + episode: 11 + screen_size: 1080p + type: episode + +? "[SuperGroup].Show.Name.-.Still.Name.-.11.[1080p]" +: release_group: SuperGroup + title: Show Name + alternative_title: Still Name + episode: 11 + screen_size: 1080p + type: episode + +? "[SuperGroup].Show.Name.-.462" +: release_group: SuperGroup + title: Show Name + episode: 462 + type: episode + +? Show.Name.10.720p +: title: Show Name + episode: 10 + screen_size: 720p + type: episode + +? "[Group].Show.Name.G2.-.19.[1080p]" +: release_group: Group + title: Show Name G2 + episode: 19 + screen_size: 1080p + type: episode + +? "[Group].Show.Name.S2.-.19.[1080p]" +? /Show.Name.S2/[Group].Show.Name.S2.-.19.[1080p] +? /Show Name S2/[Group].Show.Name.S2.-.19.[1080p] +: options: -T 'Show Name S2' + release_group: Group + title: Show Name S2 + episode: 19 + screen_size: 1080p + type: episode + +? "[ABC]_Show_Name_001.mkv" +: release_group: ABC + title: Show Name + episode: 1 + container: mkv + type: episode + +? 003-005. Show Name - Ep Name.mkv +: episode: [3, 4, 5] + title: Show Name + episode_title: Ep Name + container: mkv + type: episode + +? 003. Show Name - Ep Name.mkv +: episode: 3 + title: Show Name + episode_title: Ep Name + container: mkv + type: episode + +? 165.Show Name.s08e014 +: absolute_episode: 165 + title: Show Name + season: 8 + episode: 14 + type: episode + +? Show Name - 16x03-05 - 313-315 +? Show.Name.16x03-05.313-315-GROUP +? Show Name 16x03-05 313-315 +? Show Name - 313-315 - s16e03-05 +? Show.Name.313-315.s16e03-05 +? Show Name 313-315 s16e03-05 +: title: Show Name + absolute_episode: [313, 314, 315] + season: 16 + episode: [3, 4, 5] + type: episode + +? Show Name 13-16 +: title: Show Name + episode: [13, 14, 15, 16] + type: episode + +? Show Name 804 vostfr HD +: options: --episode-prefer-number + title: Show Name + episode: 804 + subtitle_language: fr + other: HD + type: episode + +? "[Doki] Re Zero kara Hajimeru Isekai Seikatsu - 01 1920x1080 Hi10P BD FLAC [7F64383D].mkv" +: release_group: Doki + title: Re Zero kara Hajimeru Isekai Seikatsu + episode: 1 + screen_size: 1080p + aspect_ratio: 1.778 + video_profile: High 10 + color_depth: 10-bit + source: Blu-ray + audio_codec: FLAC + crc32: 7F64383D + container: mkv + type: episode + +? Shark Tank (AU) - S02E01 - HDTV-720p.mkv +: title: Shark Tank + country: AU + season: 2 + episode: 1 + source: HDTV + screen_size: 720p + container: mkv + type: episode + +? "[HorribleSubs] Garo - Vanishing Line - 01 [1080p].mkv" +: release_group: HorribleSubs + title: Garo + alternative_title: Vanishing Line + episode: 1 + screen_size: 1080p + container: mkv + type: episode + +? "[HorribleSubs] Yowamushi Pedal - Glory Line - 01 [1080p].mkv" +: release_group: HorribleSubs + title: Yowamushi Pedal + alternative_title: Glory Line + episode: 1 + screen_size: 1080p + container: mkv + type: episode + +? c:\Temp\autosubliminal\completed\2 Broke Girls\Season 01\2 Broke Girls - S01E01 - HDTV-720p Proper - x264 AC3 - IMMERSE - [2011-09-19].mkv +: title: 2 Broke Girls + season: 1 + episode: 1 + source: HDTV + screen_size: 720p + other: Proper + video_codec: H.264 + audio_codec: Dolby Digital + release_group: IMMERSE + date: 2011-09-19 + container: mkv + type: episode + +? c:\Temp\postprocessing\Marvels.Agents.of.S.H.I.E.L.D.s01e02.0.8.4.720p.WEB.DL.mkv +: title: Marvels Agents of S.H.I.E.L.D. + season: 1 + episode: 2 + episode_title: 0.8.4. + screen_size: 720p + source: Web + container: mkv + type: episode + +? Mind.Field.S02E06.The.Power.of.Suggestion.1440p.H264.WEBDL.Subtitles +: title: Mind Field + season: 2 + episode: 6 + episode_title: The Power of Suggestion + screen_size: 1440p + video_codec: H.264 + source: Web + subtitle_language: und + type: episode + +? The Power of Suggestion - Mind Field S2 (Ep 6) (1440p_24fps_H264-384kbit_AAC 6Ch).mp4 +: title: The Power of Suggestion + alternative_title: Mind Field + season: 2 + episode: 6 + screen_size: 1440p + frame_rate: 24fps + video_codec: H.264 + audio_bit_rate: 384Kbps + audio_codec: AAC + audio_channels: '5.1' + container: mp4 + type: episode + +? Mind.Field.S02E06.The.Power.of.Suggestion.1440p.H264.WEBDL.Subtitles/The Power of Suggestion - Mind Field S2 (Ep 6) (1440p_24fps_H264-384kbit_AAC 6Ch).mp4 +: season: 2 + episode: 6 + title: The Power of Suggestion + alternative_title: Mind Field + screen_size: 1440p + frame_rate: 24fps + video_codec: H.264 + source: Web + subtitle_language: und + audio_bit_rate: 384Kbps + audio_codec: AAC + audio_channels: '5.1' + container: mp4 + type: episode + +? Mind.Field.S02E06.The.Power.of.Suggestion.1440p.H264.WEBDL.Subtitles/The Power of Suggestion - Mind Field S2 (Ep 6) (English).srt +: title: Mind Field + season: 2 + episode: 6 + episode_title: The Power of Suggestion + screen_size: 1440p + video_codec: H.264 + source: Web + subtitle_language: en + container: srt + type: episode + +? Mind.Field.S02E06.The.Power.of.Suggestion.1440p.H264.WEBDL.Subtitles/The Power of Suggestion - Mind Field S2 (Ep 6) (Korean).srt +: title: Mind Field + season: 2 + episode: 6 + episode_title: The Power of Suggestion + screen_size: 1440p + video_codec: H.264 + source: Web + subtitle_language: ko + container: srt + type: episode + +? '[HorribleSubs] Overlord II - 01 [1080p] 19.1mbits - 120fps.mkv' +: release_group: HorribleSubs + title: Overlord II + episode: 1 + screen_size: 1080p + video_bit_rate: 19.1Mbps + frame_rate: 120fps + container: mkv + type: episode + +? One Piece - 720 +: title: One Piece + season: 7 + episode: 20 + type: episode + +? foobar.213.avi +: options: -E + title: foobar + episode: 213 + container: avi + type: episode + +? FooBar - 360 368p-Grp +: options: -E + title: FooBar + episode: 360 + screen_size: 368p + release_group: Grp + type: episode + +? wwiis.most.daring.raids.s01e04.storming.mussolinis.island.1080p.web.h.264-edhd-sample.mkv +: title: wwiis most daring raids + season: 1 + episode: 4 + episode_title: storming mussolinis island + screen_size: 1080p + source: Web + video_codec: H.264 + release_group: edhd + other: Sample + container: mkv + type: episode + +? WWIIs.Most.Daring.Raids.S01E04.Storming.Mussolinis.Island.1080p.WEB.h264-EDHD/wwiis.most.daring.raids.s01e04.storming.mussolinis.island.1080p.web.h.264-edhd-sample.mkv +: title: wwiis most daring raids + season: 1 + episode: 4 + episode_title: Storming Mussolinis Island + screen_size: 1080p + source: Web + video_codec: H.264 + release_group: edhd + other: Sample + container: mkv + type: episode + +? dcs.legends.of.tomorrow.s02e01.1080p.bluray.x264-rovers.proof +: title: dcs legends of tomorrow + season: 2 + episode: 1 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: rovers + other: Proof + type: episode + +? dcs.legends.of.tomorrow.s02e01.720p.bluray.x264-demand.sample.mkv +: title: dcs legends of tomorrow + season: 2 + episode: 1 + screen_size: 720p + source: Blu-ray + video_codec: H.264 + release_group: demand + other: Sample + container: mkv + type: episode + +? Season 06/e01.1080p.bluray.x264-wavey-obfuscated.mkv +: season: 6 + episode: 1 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + title: wavey + other: Obfuscated + container: mkv + type: episode + +? Hells.Kitchen.US.S17E08.1080p.HEVC.x265-MeGusta-Obfuscated/c48db7d2aeb040e8a920a9fd6effcbf4.mkv +: title: Hells Kitchen + country: US + season: 17 + episode: 8 + screen_size: 1080p + video_codec: H.265 + release_group: MeGusta + other: Obfuscated + uuid: c48db7d2aeb040e8a920a9fd6effcbf4 + container: mkv + type: episode + +? Blue.Bloods.S08E09.1080p.HEVC.x265-MeGusta-Obfuscated/afaae96ae7a140e0981ced2a79221751.mkv +: title: Blue Bloods + season: 8 + episode: 9 + screen_size: 1080p + video_codec: H.265 + release_group: MeGusta + other: Obfuscated + container: mkv + type: episode + +? MacGyver.2016.S02E09.CD-ROM.and.Hoagie.Foil.1080p.AMZN.WEBRip.DDP5.1.x264-NTb-Scrambled/c329b27187d44a94b4a25b21502db552.mkv +: title: MacGyver + year: 2016 + season: 2 + episode: 9 + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + other: [Rip, Obfuscated] + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + release_group: NTb + uuid: c329b27187d44a94b4a25b21502db552 + container: mkv + type: episode + +? The.Late.Late.Show.with.James.Corden.2017.11.27.Armie.Hammer.Juno.Temple.Charlie.Puth.1080p.AMZN.WEB-DL.DDP2.0.H.264-monkee-Scrambled/42e7e8a48eb7454aaebebcf49705ce41.mkv +: title: The Late Late Show with James Corden + date: 2017-11-27 + episode_title: Armie Hammer Juno Temple Charlie Puth + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + audio_codec: Dolby Digital Plus + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + other: Obfuscated + uuid: 42e7e8a48eb7454aaebebcf49705ce41 + container: mkv + type: episode + +? Educating Greater Manchester S01E07 720p HDTV x264-PLUTONiUM-AsRequested +: title: Educating Greater Manchester + season: 1 + episode: 7 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: PLUTONiUM + other: Repost + type: episode + +? Im A Celebrity Get Me Out Of Here S17E14 HDTV x264-PLUTONiUM-xpost +: title: Im A Celebrity Get Me Out Of Here + season: 17 + episode: 14 + source: HDTV + video_codec: H.264 + release_group: PLUTONiUM + other: Repost + type: episode + +? Tales S01E08 All I Need Method Man Featuring Mary J Blige 720p BET WEBRip AAC2 0 x264-RTN-xpost +: title: Tales + season: 1 + episode: 8 + episode_title: All I Need Method Man Featuring Mary J Blige + screen_size: 720p + source: Web + other: [Rip, Repost] + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: RTN + type: episode + +? This is Us S01E11 Herzensangelegenheiten German DL WS DVDRip x264-CDP-xpost +: options: --exclude country + title: This is Us + season: 1 + episode: 11 + episode_title: Herzensangelegenheiten + language: + - de + - mul + other: + - Widescreen + - Rip + - Repost + source: DVD + video_codec: H.264 + release_group: CDP + type: episode + +? The Girlfriend Experience S02E10 1080p WEB H264-STRiFE-postbot +: title: The Girlfriend Experience + season: 2 + episode: 10 + screen_size: 1080p + source: Web + video_codec: H.264 + release_group: STRiFE + other: Repost + type: episode + +? The.Girlfriend.Experience.S02E10.1080p.WEB.H264-STRiFE-postbot/90550c1adaf44c47b60d24f59603bb98.mkv +: title: The Girlfriend Experience + season: 2 + episode: 10 + screen_size: 1080p + source: Web + video_codec: H.264 + release_group: STRiFE + other: Repost + uuid: 90550c1adaf44c47b60d24f59603bb98 + container: mkv + type: episode + +? 24.S01E02.1080p.BluRay.REMUX.AVC.DD.2.0-EPSiLON-xpost/eb518eaf33f641a1a8c6e0973a67aec2.mkv +: title: '24' + season: 1 + episode: 2 + screen_size: 1080p + source: Blu-ray + other: [Remux, Repost] + video_codec: H.264 + audio_codec: Dolby Digital + audio_channels: '2.0' + release_group: EPSiLON + uuid: eb518eaf33f641a1a8c6e0973a67aec2 + container: mkv + type: episode + +? Educating.Greater.Manchester.S01E02.720p.HDTV.x264-PLUTONiUM-AsRequested/47fbcb2393aa4b5cbbb340d3173ca1a9.mkv +: title: Educating Greater Manchester + season: 1 + episode: 2 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: PLUTONiUM + other: Repost + uuid: 47fbcb2393aa4b5cbbb340d3173ca1a9 + container: mkv + type: episode + +? Stranger.Things.S02E05.Chapter.Five.Dig.Dug.720p.NF.WEBRip.DD5.1.x264-PSYPHER-AsRequested-Obfuscated +: title: Stranger Things + season: 2 + episode: 5 + episode_title: Chapter Five Dig Dug + screen_size: 720p + streaming_service: Netflix + source: Web + other: [Rip, Repost, Obfuscated] + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: PSYPHER + type: episode + +? Show.Name.-.Season.1.3.4-.Mp4.1080p +: title: Show Name + season: [1, 3, 4] + container: mp4 + screen_size: 1080p + type: episode + +? Bones.S03.720p.HDTV.x264-SCENE +: title: Bones + season: 3 + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: SCENE + type: episode + +? shes.gotta.have.it.s01e08.720p.web.x264-strife.mkv +: title: shes gotta have it + season: 1 + episode: 8 + screen_size: 720p + source: Web + video_codec: H.264 + release_group: strife + type: episode + +? DuckTales.2017.S01E10.The.Missing.Links.of.Moorshire.PDTV.H.264.MP2-KIDKAT +: title: DuckTales + year: 2017 + season: 1 + episode: 10 + episode_title: The Missing Links of Moorshire + source: Digital TV + video_codec: H.264 + audio_codec: MP2 + release_group: KIDKAT + type: episode \ No newline at end of file diff --git a/libs/guessit/test/movies.yml b/libs/guessit/test/movies.yml index a132b116..642012a9 100644 --- a/libs/guessit/test/movies.yml +++ b/libs/guessit/test/movies.yml @@ -5,16 +5,17 @@ : title: Fear and Loathing in Las Vegas year: 1998 screen_size: 720p - format: HD-DVD + source: HD-DVD audio_codec: DTS - video_codec: h264 + video_codec: H.264 container: mkv release_group: ESiR ? Movies/El Dia de la Bestia (1995)/El.dia.de.la.bestia.DVDrip.Spanish.DivX.by.Artik[SEDG].avi : title: El Dia de la Bestia year: 1995 - format: DVD + source: DVD + other: Rip language: spanish video_codec: DivX release_group: Artik[SEDG] @@ -23,37 +24,40 @@ ? Movies/Dark City (1998)/Dark.City.(1998).DC.BDRip.720p.DTS.X264-CHD.mkv : title: Dark City year: 1998 - format: BluRay + source: Blu-ray + other: Rip screen_size: 720p audio_codec: DTS - video_codec: h264 + video_codec: H.264 release_group: CHD ? Movies/Sin City (BluRay) (2005)/Sin.City.2005.BDRip.720p.x264.AC3-SEPTiC.mkv : title: Sin City year: 2005 - format: BluRay + source: Blu-ray + other: Rip screen_size: 720p - video_codec: h264 - audio_codec: AC3 + video_codec: H.264 + audio_codec: Dolby Digital release_group: SEPTiC ? Movies/Borat (2006)/Borat.(2006).R5.PROPER.REPACK.DVDRip.XviD-PUKKA.avi : title: Borat year: 2006 proper_count: 2 - format: DVD - other: [ R5, Proper ] - video_codec: XviD + source: DVD + other: [ Region 5, Proper, Rip ] + video_codec: Xvid release_group: PUKKA ? "[XCT].Le.Prestige.(The.Prestige).DVDRip.[x264.HP.He-Aac.{Fr-Eng}.St{Fr-Eng}.Chaps].mkv" : title: Le Prestige - format: DVD - video_codec: h264 - video_profile: HP + source: DVD + other: Rip + video_codec: H.264 + video_profile: High audio_codec: AAC - audio_profile: HE + audio_profile: High Efficiency language: [ french, english ] subtitle_language: [ french, english ] release_group: Chaps @@ -61,23 +65,24 @@ ? Battle Royale (2000)/Battle.Royale.(Batoru.Rowaiaru).(2000).(Special.Edition).CD1of2.DVDRiP.XviD-[ZeaL].avi : title: Battle Royale year: 2000 - edition: Special Edition + edition: Special cd: 1 cd_count: 2 - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: ZeaL ? Movies/Brazil (1985)/Brazil_Criterion_Edition_(1985).CD2.avi : title: Brazil - edition: Criterion Edition + edition: Criterion year: 1985 cd: 2 ? Movies/Persepolis (2007)/[XCT] Persepolis [H264+Aac-128(Fr-Eng)+ST(Fr-Eng)+Ind].mkv : title: Persepolis year: 2007 - video_codec: h264 + video_codec: H.264 audio_codec: AAC language: [ French, English ] subtitle_language: [ French, English ] @@ -86,17 +91,18 @@ ? Movies/Toy Story (1995)/Toy Story [HDTV 720p English-Spanish].mkv : title: Toy Story year: 1995 - format: HDTV + source: HDTV screen_size: 720p language: [ english, spanish ] ? Movies/Office Space (1999)/Office.Space.[Dual-DVDRip].[Spanish-English].[XviD-AC3-AC3].[by.Oswald].avi : title: Office Space year: 1999 - format: DVD + other: [Dual Audio, Rip] + source: DVD language: [ english, spanish ] - video_codec: XviD - audio_codec: AC3 + video_codec: Xvid + audio_codec: Dolby Digital ? Movies/Wild Zero (2000)/Wild.Zero.DVDivX-EPiC.avi : title: Wild Zero @@ -106,30 +112,33 @@ ? movies/Baraka_Edition_Collector.avi : title: Baraka - edition: Collector Edition + edition: Collector ? Movies/Blade Runner (1982)/Blade.Runner.(1982).(Director's.Cut).CD1.DVDRip.XviD.AC3-WAF.avi : title: Blade Runner year: 1982 - edition: Director's cut + edition: Director's Cut cd: 1 - format: DVD - video_codec: XviD - audio_codec: AC3 + source: DVD + other: Rip + video_codec: Xvid + audio_codec: Dolby Digital release_group: WAF ? movies/American.The.Bill.Hicks.Story.2009.DVDRip.XviD-EPiSODE.[UsaBit.com]/UsaBit.com_esd-americanbh.avi : title: American The Bill Hicks Story year: 2009 - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: EPiSODE website: UsaBit.com ? movies/Charlie.And.Boots.DVDRip.XviD-TheWretched/wthd-cab.avi : title: Charlie And Boots - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: TheWretched ? movies/Steig Larsson Millenium Trilogy (2009) BRrip 720 AAC x264/(1)The Girl With The Dragon Tattoo (2009) BRrip 720 AAC x264.mkv @@ -137,17 +146,19 @@ #film_title: Steig Larsson Millenium Trilogy #film: 1 year: 2009 - format: BluRay + source: Blu-ray + other: [Reencoded, Rip] audio_codec: AAC - video_codec: h264 + video_codec: H.264 screen_size: 720p ? movies/Greenberg.REPACK.LiMiTED.DVDRip.XviD-ARROW/arw-repack-greenberg.dvdrip.xvid.avi : title: Greenberg - format: DVD - video_codec: XviD + source: DVD + video_codec: Xvid release_group: ARROW - other: ['Proper', 'Limited'] + other: [Proper, Rip] + edition: Limited proper_count: 1 ? Movies/Fr - Paris 2054, Renaissance (2005) - De Christian Volckman - (Film Divx Science Fiction Fantastique Thriller Policier N&B).avi @@ -160,14 +171,16 @@ : title: Avida year: 2006 language: french - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: PROD ? Movies/Alice in Wonderland DVDRip.XviD-DiAMOND/dmd-aw.avi : title: Alice in Wonderland - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: DiAMOND ? Movies/Ne.Le.Dis.A.Personne.Fr 2 cd/personnea_mp.avi @@ -179,49 +192,54 @@ : title: Bunker Palace Hôtel year: 1989 language: french - format: VHS + source: VHS + other: Rip ? Movies/21 (2008)/21.(2008).DVDRip.x264.AC3-FtS.[sharethefiles.com].mkv : title: "21" year: 2008 - format: DVD - video_codec: h264 - audio_codec: AC3 + source: DVD + other: Rip + video_codec: H.264 + audio_codec: Dolby Digital release_group: FtS website: sharethefiles.com ? Movies/9 (2009)/9.2009.Blu-ray.DTS.720p.x264.HDBRiSe.[sharethefiles.com].mkv : title: "9" year: 2009 - format: BluRay + source: Blu-ray audio_codec: DTS screen_size: 720p - video_codec: h264 + video_codec: H.264 release_group: HDBRiSe website: sharethefiles.com ? Movies/Mamma.Mia.2008.DVDRip.AC3.XviD-CrazyTeam/Mamma.Mia.2008.DVDRip.AC3.XviD-CrazyTeam.avi : title: Mamma Mia year: 2008 - format: DVD - audio_codec: AC3 - video_codec: XviD + source: DVD + other: Rip + audio_codec: Dolby Digital + video_codec: Xvid release_group: CrazyTeam ? Movies/M.A.S.H. (1970)/MASH.(1970).[Divx.5.02][Dual-Subtitulos][DVDRip].ogm : title: MASH year: 1970 video_codec: DivX - format: DVD + source: DVD + other: [Dual Audio, Rip] ? Movies/The Doors (1991)/09.03.08.The.Doors.(1991).BDRip.720p.AC3.X264-HiS@SiLUHD-English.[sharethefiles.com].mkv : title: The Doors year: 1991 date: 2008-03-09 - format: BluRay + source: Blu-ray + other: Rip screen_size: 720p - audio_codec: AC3 - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 release_group: HiS@SiLUHD language: english website: sharethefiles.com @@ -231,17 +249,18 @@ title: The Doors year: 1991 date: 2008-03-09 - format: BluRay + source: Blu-ray + other: Rip screen_size: 720p - audio_codec: AC3 - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 release_group: HiS@SiLUHD language: english website: sharethefiles.com ? Movies/Ratatouille/video_ts-ratatouille.srt : title: Ratatouille - format: DVD + source: DVD # Removing this one because 001 is guessed as an episode number. # ? Movies/001 __ A classer/Fantomas se déchaine - Louis de Funès.avi @@ -251,18 +270,20 @@ : title: Comme une Image year: 2004 language: french - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: NTK website: www.divx-overnet.com ? Movies/Fantastic Mr Fox/Fantastic.Mr.Fox.2009.DVDRip.{x264+LC-AAC.5.1}{Fr-Eng}{Sub.Fr-Eng}-™.[sharethefiles.com].mkv : title: Fantastic Mr Fox year: 2009 - format: DVD - video_codec: h264 + source: DVD + other: Rip + video_codec: H.264 audio_codec: AAC - audio_profile: LC + audio_profile: Low Complexity audio_channels: "5.1" language: [ french, english ] subtitle_language: [ french, english ] @@ -271,8 +292,9 @@ ? Movies/Somewhere.2010.DVDRip.XviD-iLG/i-smwhr.avi : title: Somewhere year: 2010 - format: DVD - video_codec: XviD + source: DVD + other: Rip + video_codec: Xvid release_group: iLG ? Movies/Moon_(2009).mkv @@ -338,21 +360,21 @@ ? /public/uTorrent/Downloads Finished/Movies/Indiana.Jones.and.the.Temple.of.Doom.1984.HDTV.720p.x264.AC3.5.1-REDµX/Indiana.Jones.and.the.Temple.of.Doom.1984.HDTV.720p.x264.AC3.5.1-REDµX.mkv : title: Indiana Jones and the Temple of Doom year: 1984 - format: HDTV + source: HDTV screen_size: 720p - video_codec: h264 - audio_codec: AC3 + video_codec: H.264 + audio_codec: Dolby Digital audio_channels: "5.1" release_group: REDµX ? The.Director’s.Notebook.2006.Blu-Ray.x264.DXVA.720p.AC3-de[42].mkv : title: The Director’s Notebook year: 2006 - format: BluRay - video_codec: h264 + source: Blu-ray + video_codec: H.264 video_api: DXVA screen_size: 720p - audio_codec: AC3 + audio_codec: Dolby Digital release_group: de[42] @@ -360,17 +382,18 @@ : title: Cosmopolis year: 2012 screen_size: 720p - video_codec: h264 + video_codec: H.264 release_group: AN0NYM0US[bb] - format: BluRay - other: Limited + source: Blu-ray + edition: Limited ? movies/La Science des Rêves (2006)/La.Science.Des.Reves.FRENCH.DVDRip.XviD-MP-AceBot.avi : title: La Science des Rêves year: 2006 - format: DVD - video_codec: XviD - video_profile: MP + source: DVD + other: Rip + video_codec: Xvid + video_profile: Main release_group: AceBot language: French @@ -381,8 +404,8 @@ : title: The Rum Diary year: 2011 screen_size: 1080p - format: BluRay - video_codec: h264 + source: Blu-ray + video_codec: H.264 audio_codec: DTS release_group: D-Z0N3 @@ -390,8 +413,8 @@ : title: Life Of Pi year: 2012 screen_size: 1080p - format: BluRay - video_codec: h264 + source: Blu-ray + video_codec: H.264 audio_codec: DTS release_group: D-Z0N3 @@ -399,28 +422,28 @@ : title: The Kings Speech year: 2010 screen_size: 1080p - format: BluRay + source: Blu-ray audio_codec: DTS - video_codec: h264 + video_codec: H.264 release_group: D Z0N3 ? Street.Kings.2008.BluRay.1080p.DTS.x264.dxva EuReKA.mkv : title: Street Kings year: 2008 - format: BluRay + source: Blu-ray screen_size: 1080p audio_codec: DTS - video_codec: h264 + video_codec: H.264 video_api: DXVA release_group: EuReKA ? 2001.A.Space.Odyssey.1968.HDDVD.1080p.DTS.x264.dxva EuReKA.mkv : title: 2001 A Space Odyssey year: 1968 - format: HD-DVD + source: HD-DVD screen_size: 1080p audio_codec: DTS - video_codec: h264 + video_codec: H.264 video_api: DXVA release_group: EuReKA @@ -428,24 +451,25 @@ : title: "2012" year: 2009 screen_size: 720p - format: BluRay - video_codec: h264 + source: Blu-ray + video_codec: H.264 audio_codec: DTS release_group: WiKi ? /share/Download/movie/Dead Man Down (2013) BRRiP XViD DD5_1 Custom NLSubs =-_lt Q_o_Q gt-=_/XD607ebb-BRc59935-5155473f-1c5f49/XD607ebb-BRc59935-5155473f-1c5f49.avi : title: Dead Man Down year: 2013 - format: BluRay - video_codec: XviD + source: Blu-ray + other: [Reencoded, Rip] + video_codec: Xvid audio_channels: "5.1" - audio_codec: DolbyDigital + audio_codec: Dolby Digital uuid: XD607ebb-BRc59935-5155473f-1c5f49 ? Pacific.Rim.3D.2013.COMPLETE.BLURAY-PCH.avi : title: Pacific Rim year: 2013 - format: BluRay + source: Blu-ray other: - Complete - 3D @@ -457,33 +481,33 @@ language: - French - English - format: DVD - other: NTSC + source: DVD + other: [Straight to Video, Read NFO, NTSC] ? Immersion.French.2011.STV.READNFO.QC.FRENCH.NTSC.DVDR.nfo : title: Immersion French year: 2011 language: French - format: DVD - other: NTSC + source: DVD + other: [Straight to Video, Read NFO, NTSC] ? Immersion.French.2011.STV.READNFO.QC.NTSC.DVDR.nfo : title: Immersion language: French year: 2011 - format: DVD - other: NTSC + source: DVD + other: [Straight to Video, Read NFO, NTSC] ? French.Immersion.2011.STV.READNFO.QC.ENGLISH.NTSC.DVDR.nfo : title: French Immersion year: 2011 language: ENGLISH - format: DVD - other: NTSC + source: DVD + other: [Straight to Video, Read NFO, NTSC] ? Howl's_Moving_Castle_(2004)_[720p,HDTV,x264,DTS]-FlexGet.avi -: video_codec: h264 - format: HDTV +: video_codec: H.264 + source: HDTV title: Howl's Moving Castle screen_size: 720p year: 2004 @@ -494,43 +518,42 @@ : screen_size: 1080p year: 2008 language: French - video_codec: h264 + video_codec: H.264 title: Pirates de langkasuka release_group: AsiaRa ? Masala (2013) Telugu Movie HD DVDScr XviD - Exclusive.avi : year: 2013 - video_codec: XviD + video_codec: Xvid title: Masala - format: HD-DVD + source: HD-DVD other: Screener - language: Telugu release_group: Exclusive ? Django Unchained 2012 DVDSCR X264 AAC-P2P.nfo : year: 2012 other: Screener - video_codec: h264 + video_codec: H.264 title: Django Unchained audio_codec: AAC - format: DVD + source: DVD release_group: P2P ? Ejecutiva.En.Apuros(2009).BLURAY.SCR.Xvid.Spanish.LanzamientosD.nfo : year: 2009 other: Screener - format: BluRay - video_codec: XviD + source: Blu-ray + video_codec: Xvid language: Spanish title: Ejecutiva En Apuros ? Die.Schluempfe.2.German.DL.1080p.BluRay.x264-EXQUiSiTE.mkv : title: Die Schluempfe 2 - format: BluRay + source: Blu-ray language: - Multiple languages - German - video_codec: h264 + video_codec: H.264 release_group: EXQUiSiTE screen_size: 1080p @@ -538,96 +561,99 @@ : title: Rocky year: 1976 subtitle_language: French - format: BluRay - video_codec: h264 - audio_codec: AC3 + source: Blu-ray + other: [Reencoded, Rip] + video_codec: H.264 + audio_codec: Dolby Digital release_group: FUNKY ? REDLINE (BD 1080p H264 10bit FLAC) [3xR].mkv : title: REDLINE - format: BluRay - video_codec: h264 - video_profile: 10bit + source: Blu-ray + video_codec: H.264 + color_depth: 10-bit audio_codec: FLAC screen_size: 1080p ? The.Lizzie.McGuire.Movie.(2003).HR.DVDRiP.avi : title: The Lizzie McGuire Movie year: 2003 - format: DVD - other: HR + source: DVD + other: [High Resolution, Rip] ? Hua.Mulan.BRRIP.MP4.x264.720p-HR.avi : title: Hua Mulan - video_codec: h264 - format: BluRay + video_codec: H.264 + source: Blu-ray screen_size: 720p - other: HR + other: [Reencoded, Rip] + release_group: HR ? Dr.Seuss.The.Lorax.2012.DVDRip.LiNE.XviD.AC3.HQ.Hive-CM8.mp4 -: video_codec: XviD +: video_codec: Xvid title: Dr Seuss The Lorax - format: DVD - other: LiNE + source: DVD + other: [Rip, Line Audio] year: 2012 - audio_codec: AC3 - audio_profile: HQ + audio_codec: Dolby Digital + audio_profile: High Quality release_group: Hive-CM8 ? "Star Wars: Episode IV - A New Hope (2004) Special Edition.MKV" : title: "Star Wars: Episode IV" alternative_title: A New Hope year: 2004 - edition: Special Edition + edition: Special ? Dr.LiNE.The.Lorax.2012.DVDRip.LiNE.XviD.AC3.HQ.Hive-CM8.mp4 -: video_codec: XviD +: video_codec: Xvid title: Dr LiNE The Lorax - format: DVD - other: LiNE + source: DVD + other: [Rip, Line Audio] year: 2012 - audio_codec: AC3 - audio_profile: HQ + audio_codec: Dolby Digital + audio_profile: High Quality release_group: Hive-CM8 ? Dr.LiNE.The.Lorax.2012.DVDRip.XviD.AC3.HQ.Hive-CM8.mp4 -: video_codec: XviD +: video_codec: Xvid title: Dr LiNE The Lorax - format: DVD + source: DVD + other: Rip year: 2012 - audio_codec: AC3 - audio_profile: HQ + audio_codec: Dolby Digital + audio_profile: High Quality release_group: Hive-CM8 ? Perfect Child-2007-TRUEFRENCH-TVRip.Xvid-h@mster.avi : release_group: h@mster title: Perfect Child - video_codec: XviD + video_codec: Xvid language: French - format: TV + source: TV + other: Rip year: 2007 ? entre.ciel.et.terre.(1994).dvdrip.h264.aac-psypeon.avi : audio_codec: AAC - format: DVD + source: DVD + other: Rip release_group: psypeon title: entre ciel et terre - video_codec: h264 + video_codec: H.264 year: 1994 ? Yves.Saint.Laurent.2013.FRENCH.DVDSCR.MD.XviD-ViVARiUM.avi -: format: DVD +: source: DVD language: French - other: - - MD - - Screener + other: [Screener, Mic Dubbed] release_group: ViVARiUM title: Yves Saint Laurent - video_codec: XviD + video_codec: Xvid year: 2013 ? Echec et Mort - Hard to Kill - Steven Seagal Multi 1080p BluRay x264 CCATS.avi -: format: BluRay +: source: Blu-ray language: Multiple languages release_group: CCATS screen_size: 1080p @@ -635,7 +661,7 @@ alternative_title: - Hard to Kill - Steven Seagal - video_codec: h264 + video_codec: H.264 ? Paparazzi - Timsit/Lindon (MKV 1080p tvripHD) : options: -n @@ -644,36 +670,37 @@ - Timsit - Lindon screen_size: 1080p - container: MKV - format: HDTV + container: mkv + source: HDTV + other: Rip ? some.movie.720p.bluray.x264-mind : title: some movie screen_size: 720p - video_codec: h264 + video_codec: H.264 release_group: mind - format: BluRay + source: Blu-ray ? Dr LiNE The Lorax 720p h264 BluRay : title: Dr LiNE The Lorax screen_size: 720p - video_codec: h264 - format: BluRay + video_codec: H.264 + source: Blu-ray #TODO: Camelcase implementation #? BeatdownFrenchDVDRip.mkv #: options: -c # title: Beatdown # language: French -# format: DVD +# source: DVD #? YvesSaintLaurent2013FrenchDVDScrXvid.avi #: options: -c -# format: DVD +# source: DVD # language: French # other: Screener # title: Yves saint laurent -# video_codec: XviD +# video_codec: Xvid # year: 2013 @@ -682,58 +709,54 @@ title: Elle s en va ? FooBar.7.PDTV-FlexGet -: format: DVB +: source: Digital TV release_group: FlexGet title: FooBar 7 ? h265 - HEVC Riddick Unrated Director Cut French 1080p DTS.mkv : audio_codec: DTS - edition: Director's cut + edition: [Unrated, Director's Cut] language: fr screen_size: 1080p title: Riddick - other: Unrated - video_codec: h265 + video_codec: H.265 ? "[h265 - HEVC] Riddick Unrated Director Cut French [1080p DTS].mkv" : audio_codec: DTS - edition: Director's cut + edition: [Unrated, Director's Cut] language: fr screen_size: 1080p title: Riddick - other: Unrated - video_codec: h265 + video_codec: H.265 ? Barbecue-2014-French-mHD-1080p : language: fr - other: mHD + other: Micro HD screen_size: 1080p title: Barbecue year: 2014 ? Underworld Quadrilogie VO+VFF+VFQ 1080p HDlight.x264~Tonyk~Monde Infernal : language: fr - other: - - HDLight - - OV + other: [Original Video, Micro HD] screen_size: 1080p title: Underworld Quadrilogie - video_codec: h264 + video_codec: H.264 ? A Bout Portant (The Killers).PAL.Multi.DVD-R-KZ -: format: DVD +: source: DVD language: mul release_group: KZ title: A Bout Portant ? "Mise à Sac (Alain Cavalier, 1967) [Vhs.Rip.Vff]" -: format: VHS +: source: VHS language: fr title: "Mise à Sac" year: 1967 ? A Bout Portant (The Killers).PAL.Multi.DVD-R-KZ -: format: DVD +: source: DVD other: PAL language: mul release_group: KZ @@ -748,7 +771,8 @@ year: 2009 ? La Defense Lincoln (The Lincoln Lawyer) 2011 [DVDRIP][Vostfr] -: format: DVD +: source: DVD + other: Rip subtitle_language: fr title: La Defense Lincoln year: 2011 @@ -758,7 +782,7 @@ language: fr screen_size: 1080p title: Fight Club - video_codec: h265 + video_codec: H.265 ? Love Gourou (Mike Myers) - FR : language: fr @@ -766,11 +790,11 @@ ? '[h265 - hevc] transformers 2 1080p french ac3 6ch.' : audio_channels: '5.1' - audio_codec: AC3 + audio_codec: Dolby Digital language: fr screen_size: 1080p title: transformers 2 - video_codec: h265 + video_codec: H.265 ? 1.Angry.Man.1957.mkv : title: 1 Angry Man @@ -789,30 +813,29 @@ title: Looney Tunes ? Das.Appartement.German.AC3D.DL.720p.BluRay.x264-TVP -: audio_codec: AC3 - format: BluRay +: audio_codec: Dolby Digital + source: Blu-ray language: mul release_group: TVP screen_size: 720p title: Das Appartement German type: movie - video_codec: h264 + video_codec: H.264 ? Das.Appartement.GERMAN.AC3D.DL.720p.BluRay.x264-TVP -: audio_codec: AC3 - format: BluRay +: audio_codec: Dolby Digital + source: Blu-ray language: - de - mul release_group: TVP screen_size: 720p title: Das Appartement - video_codec: h264 + video_codec: H.264 ? Hyena.Road.2015.German.1080p.DL.DTSHD.Bluray.x264-pmHD -: audio_codec: DTS - audio_profile: HD - format: BluRay +: audio_codec: DTS-HD + source: Blu-ray language: - de - mul @@ -820,18 +843,931 @@ screen_size: 1080p title: Hyena Road type: movie - video_codec: h264 + video_codec: H.264 year: 2015 -? Hyena.Road.2015.German.Ep.Title.1080p.DL.DTSHD.Bluray.x264-pmHD -: audio_codec: DTS - audio_profile: HD - episode_title: German Ep Title - format: BluRay - language: mul +? Hyena.Road.2015.German.1080p.DL.DTSHD.Bluray.x264-pmHD +: audio_codec: DTS-HD + source: Blu-ray + language: + - de + - mul release_group: pmHD screen_size: 1080p title: Hyena Road type: movie - video_codec: h264 + video_codec: H.264 year: 2015 + +? Name.BDMux.720p +: title: Name + source: Blu-ray + other: Mux + screen_size: 720p + type: movie + +? Name.BRMux.720p +: title: Name + source: Blu-ray + other: [Reencoded, Mux] + screen_size: 720p + type: movie + +? Name.BDRipMux.720p +: title: Name + source: Blu-ray + other: [Rip, Mux] + screen_size: 720p + type: movie + +? Name.BRRipMux.720p +: title: Name + source: Blu-ray + other: [Reencoded, Rip, Mux] + screen_size: 720p + type: movie + +? Secondary Education (2013).mkv +: options: -T Second + title: Secondary Education + year: 2013 + type: movie + +? Mad Max Beyond Thunderdome () +: title: Mad Max Beyond Thunderdome + type: movie + +? Hacksaw Ridge 2016 Multi 2160p UHD BluRay Hevc10 HDR10 DTSHD & ATMOS 7.1 -DDR.mkv +: title: Hacksaw Ridge + year: 2016 + language: mul + screen_size: 2160p + source: Ultra HD Blu-ray + video_codec: H.265 + color_depth: 10-bit + audio_codec: [DTS-HD, Dolby Atmos] + audio_channels: '7.1' + release_group: DDR + container: mkv + type: movie + +? Special.Correspondents.2016.iTA.ENG.4K.2160p.NetflixUHD.TeamPremium.mp4 +: title: Special Correspondents + year: 2016 + language: [it, en] + screen_size: 2160p + streaming_service: Netflix + other: Ultra HD + release_group: TeamPremium + container: mp4 + type: movie + +? -Special.Correspondents.2016.iTA.ENG.4K.2160p.NetflixUHD.TeamPremium.mp4 +: alternative_title: 4K + +? -Special.Correspondents.2016.iTA.ENG.4K.2160p.NetflixUHD.TeamPremium.mp4 +: alternative_title: 2160p + +? Suicide Squad EXTENDED (2016) 2160p 4K UltraHD Blu-Ray x265 (HEVC 10bit BT709) Dolby Atmos 7.1 -DDR +: title: Suicide Squad + edition: Extended + year: 2016 + screen_size: 2160p + source: Ultra HD Blu-ray + video_codec: H.265 + color_depth: 10-bit + audio_codec: Dolby Atmos + audio_channels: '7.1' + release_group: DDR + type: movie + +? Queen - A Kind of Magic (Alternative Extended Version) 2CD 2014 +: title: Queen + alternative_title: A Kind of Magic + edition: [Alternative Cut, Extended] + cd_count: 2 + year: 2014 + type: movie + +? Jour.de.Fete.1949.ALTERNATiVE.CUT.1080p.BluRay.x264-SADPANDA[rarbg] +: title: Jour de Fete + year: 1949 + edition: Alternative Cut + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: SADPANDA[rarbg] + +? The.Movie.CONVERT.720p.HDTV.x264-C4TV +: title: The Movie + other: Converted + screen_size: 720p + source: HDTV + video_codec: H.264 + release_group: C4TV + type: movie + +? Its.A.Wonderful.Life.1946.Colorized.720p.BRRip.999MB.MkvCage.com +: title: Its A Wonderful Life + year: 1946 + other: [Colorized, Reencoded, Rip] + screen_size: 720p + source: Blu-ray + size: 999MB + website: MkvCage.com + type: movie + +? Alien DC (1979) [1080p] +: title: Alien + edition: Director's Cut + year: 1979 + screen_size: 1080p + type: movie + +? Requiem.For.A.Dream.2000.DC.1080p.BluRay.x264.anoXmous +: title: Requiem For A Dream + year: 2000 + edition: Director's Cut + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: anoXmous + type: movie + +? Before.the.Flood.2016.DOCU.1080p.WEBRip.x264.DD5.1-FGT +: title: Before the Flood + year: 2016 + other: [Documentary, Rip] + screen_size: 1080p + source: Web + video_codec: H.264 + audio_codec: Dolby Digital + audio_channels: '5.1' + release_group: FGT + type: movie + +? Zootopia.2016.HDRip.1.46Gb.Dub.MegaPeer +: title: Zootopia + year: 2016 + other: [HD, Rip] + size: 1.46GB + language: und + release_group: MegaPeer + type: movie + +? Suntan.2016.FESTiVAL.DVDRip.x264-IcHoR +: title: Suntan + year: 2016 + edition: Festival + source: DVD + other: Rip + video_codec: H.264 + release_group: IcHoR + type: movie + +? Hardwired.STV.NFOFiX.FRENCH.DVDRiP.XviD-SURViVAL +: title: Hardwired + other: [Straight to Video, Fix, Rip] + language: french + source: DVD + video_codec: Xvid + release_group: SURViVAL + -proper_count: 1 + type: movie + +? Maze.Runner.The.Scorch.Trials.OM.2015.WEB-DLRip.by.Seven +: title: Maze Runner The Scorch Trials + other: [Open Matte, Rip] + year: 2015 + source: Web + release_group: Seven + type: movie + +? Kampen Om Tungtvannet aka The Heavy Water War COMPLETE 720p x265 HEVC-Lund +: title: Kampen Om Tungtvannet aka The Heavy Water War + other: Complete + screen_size: 720p + video_codec: H.265 + release_group: Lund + type: movie + +? All.Fall.Down.x264.PROOFFIX-OUTLAWS +: title: All Fall Down + video_codec: H.264 + other: Fix + release_group: OUTLAWS + -proper_count: 1 + type: movie + +? The.Last.Survivors.2014.PROOF.SAMPLE.FiX.BDRip.x264-TOPCAT +: title: The Last Survivors + year: 2014 + other: [Fix, Rip] + source: Blu-ray + video_codec: H.264 + release_group: TOPCAT + type: movie + +? Bad Santa 2 2016 THEATRiCAL FRENCH BDRip XviD-EXTREME +: title: Bad Santa 2 + year: 2016 + edition: Theatrical + language: french + source: Blu-ray + other: Rip + video_codec: Xvid + release_group: EXTREME + type: movie + +? The Lord of the Rings The Fellowship of the Ring THEATRICAL EDITION (2001) [1080p] +: title: The Lord of the Rings The Fellowship of the Ring + edition: Theatrical + year: 2001 + screen_size: 1080p + type: movie + +? World War Z (2013) Theatrical Cut 720p BluRay x264 +: title: World War Z + year: 2013 + edition: Theatrical + screen_size: 720p + source: Blu-ray + video_codec: H.264 + type: movie + +? The Heartbreak Kid (1993) UNCUT 720p WEBRip x264 +: title: The Heartbreak Kid + year: 1993 + edition: Uncut + other: Rip + screen_size: 720p + source: Web + video_codec: H.264 + type: movie + +? Mrs.Doubtfire.1993.720p.OAR.Bluray.DTS.x264-CtrlHD +: title: Mrs Doubtfire + year: 1993 + screen_size: 720p + other: Original Aspect Ratio + source: Blu-ray + audio_codec: DTS + video_codec: H.264 + release_group: CtrlHD + type: movie + +? Aliens.SE.1986.BDRip.1080p +: title: Aliens + edition: Special + year: 1986 + source: Blu-ray + other: Rip + screen_size: 1080p + type: movie + +? 10 Cloverfield Lane.[Blu-Ray 1080p].[MULTI] +: options: --type movie + title: 10 Cloverfield Lane + source: Blu-ray + screen_size: 1080p + language: Multiple languages + type: movie + +? 007.Spectre.[HDTC.MD].[TRUEFRENCH] +: options: --type movie + title: 007 Spectre + source: HD Telecine + language: French + type: movie + +? We.Are.X.2016.LIMITED.BDRip.x264-BiPOLAR +: title: We Are X + year: 2016 + edition: Limited + source: Blu-ray + other: Rip + video_codec: H.264 + release_group: BiPOLAR + type: movie + +? The Rack (VHS) [1956] Paul Newman +: title: The Rack + source: VHS + year: 1956 + type: movie + +? Les.Magiciens.1976.VHSRip.XViD.MKO +: title: Les Magiciens + year: 1976 + source: VHS + other: Rip + video_codec: Xvid + release_group: MKO + type: movie + +? The Boss Baby 2017 720p CAM x264 AC3 TiTAN +: title: The Boss Baby + year: 2017 + screen_size: 720p + source: Camera + video_codec: H.264 + audio_codec: Dolby Digital + release_group: TiTAN + type: movie + +? The.Boss.Baby.2017.HDCAM.XviD-MrGrey +: title: The Boss Baby + year: 2017 + source: HD Camera + video_codec: Xvid + release_group: MrGrey + type: movie + +? The Martian 2015 Multi 2160p 4K UHD Bluray HEVC10 SDR DTSHD 7.1 -Zeus +: title: The Martian + year: 2015 + language: mul + screen_size: 2160p + source: Ultra HD Blu-ray + video_codec: H.265 + color_depth: 10-bit + other: Standard Dynamic Range + audio_codec: DTS-HD + audio_channels: '7.1' + release_group: Zeus + type: movie + +? Fantastic Beasts and Where to Find Them 2016 Multi 2160p UHD BluRay HEVC HDR Atmos7.1-DDR +: title: Fantastic Beasts and Where to Find Them + year: 2016 + language: mul + screen_size: 2160p + source: Ultra HD Blu-ray + video_codec: H.265 + other: HDR10 + audio_codec: Dolby Atmos + audio_channels: '7.1' + release_group: DDR + type: movie + +? Life of Pi 2012 2160p 4K BluRay HDR10 HEVC BT2020 DTSHD 7.1 subs -DDR +: title: Life of Pi + year: 2012 + screen_size: 2160p + source: Ultra HD Blu-ray + other: [HDR10, BT.2020] + subtitle_language: und + release_group: DDR + +? Captain.America.Civil.War.HDR.1080p.HEVC.10bit.BT.2020.DTS-HD.MA.7.1-VISIONPLUSHDR +: title: Captain America Civil War + other: [HDR10, BT.2020] + screen_size: 1080p + video_codec: H.265 + color_depth: 10-bit + audio_codec: DTS-HD + audio_profile: Master Audio + audio_channels: '7.1' + release_group: VISIONPLUSHDR + type: movie + +? Deadpool.2016.4K.2160p.UHD.HQ.8bit.BluRay.8CH.x265.HEVC-MZABI.mkv +: title: Deadpool + year: 2016 + screen_size: 2160p + source: Ultra HD Blu-ray + other: High Quality + color_depth: 8-bit + audio_channels: '7.1' + video_codec: H.265 + release_group: MZABI + type: movie + +? Fantastic.Beasts.and.Where.to.Find.Them.2016.2160p.4K.UHD.10bit.HDR.BluRay.7.1.x265.HEVC-MZABI.mkv +: title: Fantastic Beasts and Where to Find Them + year: 2016 + screen_size: 2160p + source: Ultra HD Blu-ray + color_depth: 10-bit + other: HDR10 + audio_channels: '7.1' + video_codec: H.265 + release_group: MZABI + container: mkv + type: movie + +? The.Arrival.4K.HDR.HEVC.10bit.BT2020.DTS.HD-MA-MadVR.HDR10.Dolby.Vision-VISIONPLUSHDR1000 +: title: The Arrival + screen_size: 2160p + other: [HDR10, BT.2020, Dolby Vision] + video_codec: H.265 + color_depth: 10-bit + audio_codec: DTS-HD + audio_profile: Master Audio + release_group: VISIONPLUSHDR1000 + type: movie + +? How To Steal A Dog.2014.BluRay.1080p.12bit.HEVC.OPUS 5.1-Hn1Dr2.mkv +: title: How To Steal A Dog + year: 2014 + source: Blu-ray + screen_size: 1080p + color_depth: 12-bit + video_codec: H.265 + audio_codec: Opus + audio_channels: '5.1' + release_group: Hn1Dr2 + container: mkv + type: movie + +? Interstelar.2014.IMAX.RUS.BDRip.x264.-HELLYWOOD.mkv +: title: Interstelar + year: 2014 + edition: IMAX + language: ru + source: Blu-ray + other: Rip + video_codec: H.264 + release_group: HELLYWOOD + container: mkv + type: movie + +? The.Dark.Knight.IMAX.EDITION.HQ.BluRay.1080p.x264.AC3.Hindi.Eng.ETRG +: title: The Dark Knight + edition: IMAX + other: High Quality + source: Blu-ray + screen_size: 1080p + video_codec: H.264 + audio_codec: Dolby Digital + language: [hindi, english] + release_group: ETRG + type: movie + +? The.Martian.2015.4K.UHD.UPSCALED-ETRG +: title: The Martian + year: 2015 + screen_size: 2160p + other: [Ultra HD, Upscaled] + release_group: ETRG + type: movie + +? Delibal 2015 720p Upscale DVDRip x264 DD5.1 AC3 +: title: Delibal + year: 2015 + screen_size: 720p + other: [Upscaled, Rip] + source: DVD + video_codec: H.264 + audio_codec: Dolby Digital + audio_channels: '5.1' + type: movie + +? Casablanca [Ultimate Collector's Edition].1942.BRRip.XviD-VLiS +: title: Casablanca + edition: [Ultimate, Collector] + year: 1942 + source: Blu-ray + other: [Reencoded, Rip] + video_codec: Xvid + release_group: VLiS + type: movie + +? Batman V Superman Dawn of Justice 2016 Extended Cut Ultimate Edition HDRip x264 AC3-DaDDy +: title: Batman V Superman Dawn of Justice + year: 2016 + edition: [Extended, Ultimate] + other: [HD, Rip] + video_codec: H.264 + audio_codec: Dolby Digital + release_group: DaDDy + type: movie + +? Stargate SG1 Ultimate Fan Collection +: title: Stargate SG1 + edition: [Ultimate, Fan] + +? The.Jungle.Book.2016.MULTi.1080p.BluRay.x264.DTS-HD.MA.7.1.DTS-HD.HRA.5.1-LeRalou +: title: The Jungle Book + year: 2016 + language: mul + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + audio_codec: DTS-HD + audio_profile: [Master Audio, High Resolution Audio] + audio_channels: ['7.1', '5.1'] + release_group: LeRalou + type: movie + +? Terminus.2015.BluRay.1080p.x264.DTS-HD.HRA.5.1-LTT +: title: Terminus + year: 2015 + source: Blu-ray + screen_size: 1080p + video_codec: H.264 + audio_codec: DTS-HD + audio_profile: High Resolution Audio + audio_channels: '5.1' + release_group: LTT + type: movie + +? Ghost.in.the.Shell.1995.1080p.Bluray.DTSES.x264-SHiTSoNy +: title: Ghost in the Shell + year: 1995 + screen_size: 1080p + source: Blu-ray + audio_codec: DTS + audio_profile: Extended Surround + +? The.Boss.Baby.2017.BluRay.1080p.DTS-ES.x264-PRoDJi +: title: The Boss Baby + year: 2017 + source: Blu-ray + screen_size: 1080p + audio_codec: DTS + audio_profile: Extended Surround + video_codec: H.264 + release_group: PRoDJi + type: movie + +? Title.2000.720p.BluRay.DDEX.x264-HDClub.mkv +: title: Title + year: 2000 + screen_size: 720p + source: Blu-ray + audio_codec: Dolby Digital + audio_profile: EX + video_codec: H.264 + release_group: HDClub + container: mkv + type: movie + +? Jack Reacher Never Go Back 2016 720p Bluray DD-EX x264-BluPanther +: title: Jack Reacher Never Go Back + year: 2016 + screen_size: 720p + source: Blu-ray + audio_codec: Dolby Digital + audio_profile: EX + video_codec: H.264 + release_group: BluPanther + type: movie + +? How To Steal A Dog.2014.BluRay.1080p.12bit.HEVC.OPUS 5.1-Hn1Dr2.mkv +: title: How To Steal A Dog + year: 2014 + source: Blu-ray + screen_size: 1080p + color_depth: 12-bit + video_codec: H.265 + audio_codec: Opus + audio_channels: '5.1' + release_group: Hn1Dr2 + container: mkv + type: movie + +? How.To.Be.Single.2016.1080p.BluRay.x264-BLOW/blow-how.to.be.single.2016.1080p.bluray.x264.mkv +: title: How To Be Single + year: 2016 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: BLOW + container: mkv + type: movie + +? After.the.Storm.2016.720p.YIFY +: title: After the Storm + year: 2016 + screen_size: 720p + release_group: YIFY + type: movie + +? Battle Royale 2000 DC (1080p Bluray x265 HEVC 10bit AAC 7.1 Japanese Tigole) +: title: Battle Royale + year: 2000 + edition: Director's Cut + screen_size: 1080p + source: Blu-ray + video_codec: H.265 + color_depth: 10-bit + audio_codec: AAC + audio_channels: '7.1' + language: jp + release_group: Tigole + +? Congo.The.Grand.Inga.Project.2013.1080p.BluRay.x264-OBiTS +: title: Congo The Grand Inga Project + year: 2013 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + release_group: OBiTS + type: movie + +? Congo.The.Grand.Inga.Project.2013.BRRip.XviD.MP3-RARBG +: title: Congo The Grand Inga Project + year: 2013 + source: Blu-ray + other: [Reencoded, Rip] + video_codec: Xvid + audio_codec: MP3 + release_group: RARBG + type: movie + +? Congo.The.Grand.Inga.Project.2013.720p.BluRay.H264.AAC-RARBG +: title: Congo The Grand Inga Project + year: 2013 + screen_size: 720p + source: Blu-ray + video_codec: H.264 + audio_codec: AAC + release_group: RARBG + type: movie + +? Mit.dem.Bauch.durch.die.Wand.SWiSSGERMAN.DOKU.DVDRiP.x264-DEFLOW +: title: Mit dem Bauch durch die Wand + language: de-CH + other: [Documentary, Rip] + source: DVD + video_codec: H.264 + release_group: DEFLOW + type: movie + +? InDefinitely.Maybe.2008.1080p.EUR.BluRay.VC-1.DTS-HD.MA.5.1-FGT +: title: InDefinitely Maybe + year: 2008 + screen_size: 1080p + source: Blu-ray + video_codec: VC-1 + audio_codec: DTS-HD + audio_profile: Master Audio + audio_channels: '5.1' + release_group: FGT + type: movie + +? Bjyukujyo Kyoushi Kan XXX 720P WEBRIP MP4-GUSH +: title: Bjyukujyo Kyoushi Kan + other: [XXX, Rip] + screen_size: 720p + source: Web + container: mp4 + release_group: GUSH + type: movie + +? The.Man.With.The.Golden.Arm.1955.1080p.BluRay.x264.DTS-FGT +: title: The Man With The Golden Arm + year: 1955 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + audio_codec: DTS + release_group: FGT + type: movie + +? blow-how.to.be.single.2016.1080p.bluray.x264.mkv +: release_group: blow + title: how to be single + year: 2016 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + container: mkv + type: movie + +? ulshd-the.right.stuff.1983.multi.1080p.bluray.x264.mkv +: release_group: ulshd + title: the right stuff + year: 1983 + language: mul + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + container: mkv + type: movie + +? FROZEN [2010] LiMiTED DVDRip H262 AAC[ ENG SUBS]-MANTESH +: title: FROZEN + year: 2010 + edition: Limited + source: DVD + other: Rip + video_codec: MPEG-2 + audio_codec: AAC + subtitle_language: english + release_group: MANTESH + type: movie + +? Family.Katta.2016.1080p.WEB-DL.H263.DD5.1.ESub-DDR +: title: Family Katta + year: 2016 + screen_size: 1080p + source: Web + video_codec: H.263 + audio_codec: Dolby Digital + audio_channels: '5.1' + subtitle_language: und + release_group: DDR + type: movie + +? Bad Boys 2 1080i.mpg2.rus.eng.ts +: title: Bad Boys 2 + screen_size: 1080i + video_codec: MPEG-2 + language: [russian, english] + container: ts + type: movie + +? Alien.Director.Cut.Ita.Eng.VP9.Opus.AlphaBot.webm +: title: Alien + edition: Director's Cut + language: [english, italian] + video_codec: VP9 + audio_codec: Opus + release_group: AlphaBot + container: webm + type: movie + +? The.Stranger.1946.US.(Kino.Classics).Bluray.1080p.LPCM.DD-2.0.x264-Grym@BTNET +: title: The Stranger + year: 1946 + country: US + source: Blu-ray + screen_size: 1080p + audio_codec: [LPCM, Dolby Digital] + audio_channels: '2.0' + video_codec: H.264 + release_group: Grym@BTNET + type: movie + +? X-Men.Apocalypse.2016.complete.hdts.pcm.TrueFrench-Scarface45.avi +: title: X-Men Apocalypse + year: 2016 + other: Complete + source: HD Telesync + audio_codec: PCM + language: french + release_group: Scarface45 + container: avi + type: movie + +? Tears.of.Steel.2012.2160p.DMRip.Eng.HDCLUB.mkv +: title: Tears of Steel + year: 2012 + screen_size: 2160p + source: Digital Master + other: Rip + language: english + release_group: HDCLUB + container: mkv + type: movie + +? "/Movies/Open Season 2 (2008)/Open Season 2 (2008) - Bluray-1080p.x264.DTS.mkv" +: options: --type movie + title: Open Season 2 + year: 2008 + source: Blu-ray + screen_size: 1080p + video_codec: H.264 + audio_codec: DTS + container: mkv + type: movie + +? Re-Animator.1985.INTEGRAL VERSION LIMITED EDITION.1080p.BluRay.REMUX.AVC.DTS-HD MA 5.1-LAZY +: title: Re-Animator + year: 1985 + edition: Limited + screen_size: 1080p + source: Blu-ray + other: Remux + video_codec: H.264 + audio_codec: DTS-HD + audio_profile: Master Audio + audio_channels: '5.1' + release_group: LAZY + type: movie + +? Test (2013) [WEBDL-1080p] [x264 AC3] [ENG+RU+PT] [NTb].mkv +: title: Test + year: 2013 + source: Web + screen_size: 1080p + video_codec: H.264 + audio_codec: Dolby Digital + language: [en, ru, pt] + release_group: NTb + container: mkv + type: movie + +? "[nextorrent.org] Bienvenue.Au.Gondwana.2016.FRENCH.DVDRiP.XViD-AViTECH.avi" +: website: nextorrent.org + title: Bienvenue Au Gondwana + year: 2016 + language: french + source: DVD + other: Rip + video_codec: Xvid + release_group: AViTECH + container: avi + type: movie + +? Star Trek First Contact (1996) Blu-Ray 1080p24 H.264 TrueHD 5.1 CtrlHD +: title: Star Trek First Contact + year: 1996 + source: Blu-ray + screen_size: 1080p + frame_rate: 24fps + video_codec: H.264 + audio_codec: Dolby TrueHD + audio_channels: '5.1' + release_group: CtrlHD + type: movie + +? The.Hobbit.The.Desolation.of.Smaug.Extended.HFR.48fps.ITA.ENG.AC3.BDRip.1080p.x264_ZMachine.mkv +: title: The Hobbit The Desolation of Smaug + edition: Extended + other: [High Frame Rate, Rip] + frame_rate: 48fps + language: [it, en] + audio_codec: Dolby Digital + source: Blu-ray + screen_size: 1080p + video_codec: H.264 + release_group: ZMachine + container: mkv + type: movie + +? Test (2013) [WEBDL-1080p] [x264 AC3] [ENG+PT+DE] [STANDARD] +: title: Test + year: 2013 + source: Web + screen_size: 1080p + video_codec: H.264 + audio_codec: Dolby Digital + language: [en, pt, de] + release_group: STANDARD + type: movie + +? Test (2013) [WEBDL-1080p] [x264 AC3] [ENG+DE+IT] [STANDARD] +: title: Test + year: 2013 + source: Web + screen_size: 1080p + video_codec: H.264 + audio_codec: Dolby Digital + language: [en, de, it] + release_group: STANDARD + type: movie + +? Ant-Man.and.the.Wasp.2018.Digital.Extras.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTG.mkv +: title: Ant-Man and the Wasp + year: 2018 + alternative_title: Digital Extras + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + release_group: NTG + type: movie + +? Ant-Man.and.the.Wasp.2018.1080p.AMZN.WEB-DL.DDP5.1.H.264-NTG.mkv +: title: Ant-Man and the Wasp + year: 2018 + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + release_group: NTG + type: movie + +? Avengers.Infinity.War.2018.3D.Hybrid.REPACK.1080p.BluRay.REMUX.AVC.Atmos-EPSiLON.mk3d +: title: Avengers Infinity War + year: 2018 + other: + - 3D + - Proper + - Remux + proper_count: 1 + screen_size: 1080p + source: Blu-ray + video_codec: H.264 + audio_codec: Dolby Atmos + release_group: EPSiLON + container: mk3d + type: movie + +? Ouija.Seance.The.Final.Game.2018.1080p.WEB-DL.DD5.1.H264-CMRG +: title: Ouija Seance The Final Game + year: 2018 + screen_size: 1080p + source: Web + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: CMRG + type: movie \ No newline at end of file diff --git a/libs/guessit/test/rules/audio_codec.yml b/libs/guessit/test/rules/audio_codec.yml index b744d7bf..9e381c34 100644 --- a/libs/guessit/test/rules/audio_codec.yml +++ b/libs/guessit/test/rules/audio_codec.yml @@ -8,23 +8,29 @@ ? +lame3.100 : audio_codec: MP3 +? +MP2 +: audio_codec: MP2 + ? +DolbyDigital ? +DD ? +Dolby Digital -: audio_codec: DolbyDigital +? +AC3 +: audio_codec: Dolby Digital + +? +DDP +? +DD+ +? +EAC3 +: audio_codec: Dolby Digital Plus ? +DolbyAtmos ? +Dolby Atmos ? +Atmos ? -Atmosphere -: audio_codec: DolbyAtmos +: audio_codec: Dolby Atmos ? +AAC : audio_codec: AAC -? +AC3 -: audio_codec: AC3 - ? +Flac : audio_codec: FLAC @@ -33,29 +39,37 @@ ? +True-HD ? +trueHD -: audio_codec: TrueHD +: audio_codec: Dolby TrueHD +? +True-HD51 +? +trueHD51 +: audio_codec: Dolby TrueHD + audio_channels: '5.1' + +? +DTSHD +? +DTS HD ? +DTS-HD -: audio_codec: DTS - audio_profile: HD +: audio_codec: DTS-HD ? +DTS-HDma -: audio_codec: DTS - audio_profile: HDMA +? +DTSMA +: audio_codec: DTS-HD + audio_profile: Master Audio ? +AC3-hq -: audio_codec: AC3 - audio_profile: HQ +: audio_codec: Dolby Digital + audio_profile: High Quality ? +AAC-HE : audio_codec: AAC - audio_profile: HE + audio_profile: High Efficiency ? +AAC-LC : audio_codec: AAC - audio_profile: LC + audio_profile: Low Complexity ? +AAC2.0 +? +AAC20 : audio_codec: AAC audio_channels: '2.0' @@ -79,5 +93,42 @@ : audio_channels: '1.0' ? DD5.1 -: audio_codec: DolbyDigital +? DD51 +: audio_codec: Dolby Digital audio_channels: '5.1' + +? -51 +: audio_channels: '5.1' + +? DTS-HD.HRA +? DTSHD.HRA +? DTS-HD.HR +? DTSHD.HR +? -HRA +? -HR +: audio_codec: DTS-HD + audio_profile: High Resolution Audio + +? DTSES +? DTS-ES +? -ES +: audio_codec: DTS + audio_profile: Extended Surround + +? DD-EX +? DDEX +? -EX +: audio_codec: Dolby Digital + audio_profile: EX + +? OPUS +: audio_codec: Opus + +? Vorbis +: audio_codec: Vorbis + +? PCM +: audio_codec: PCM + +? LPCM +: audio_codec: LPCM diff --git a/libs/guessit/test/rules/cds.yml b/libs/guessit/test/rules/cds.yml index cc63765e..d76186c6 100644 --- a/libs/guessit/test/rules/cds.yml +++ b/libs/guessit/test/rules/cds.yml @@ -7,4 +7,4 @@ ? Some.Title-DVDRIP-x264-CDP : cd: !!null release_group: CDP - video_codec: h264 + video_codec: H.264 diff --git a/libs/guessit/test/rules/country.yml b/libs/guessit/test/rules/country.yml index f2da1b20..76383180 100644 --- a/libs/guessit/test/rules/country.yml +++ b/libs/guessit/test/rules/country.yml @@ -8,3 +8,6 @@ ? This.is.us.title : title: This is us title +? This.Is.Us +: options: --no-default-config + title: This Is Us diff --git a/libs/guessit/test/rules/edition.yml b/libs/guessit/test/rules/edition.yml index bc35b85e..4b7fd986 100644 --- a/libs/guessit/test/rules/edition.yml +++ b/libs/guessit/test/rules/edition.yml @@ -2,24 +2,62 @@ # Use - marker to check inputs that should not match results. ? Director's cut ? Edition Director's cut -: edition: Director's cut +: edition: Director's Cut ? Collector ? Collector Edition ? Edition Collector -: edition: Collector Edition +: edition: Collector ? Special Edition ? Edition Special ? -Special -: edition: Special Edition +: edition: Special ? Criterion Edition ? Edition Criterion +? CC ? -Criterion -: edition: Criterion Edition +: edition: Criterion ? Deluxe ? Deluxe Edition ? Edition Deluxe -: edition: Deluxe Edition +: edition: Deluxe + +? Super Movie Alternate XViD +? Super Movie Alternative XViD +? Super Movie Alternate Cut XViD +? Super Movie Alternative Cut XViD +: edition: Alternative Cut + +? ddc +: edition: Director's Definitive Cut + +? IMAX +? IMAX Edition +: edition: IMAX + +? ultimate edition +? -ultimate +: edition: Ultimate + +? ultimate collector edition +? ultimate collector's edition +? ultimate collectors edition +? -collectors edition +? -ultimate edition +: edition: [Ultimate, Collector] + +? ultimate collectors edition dc +: edition: [Ultimate, Collector, Director's Cut] + +? fan edit +? fan edition +? fan collection +: edition: Fan + +? ultimate fan edit +? ultimate fan edition +? ultimate fan collection +: edition: [Ultimate, Fan] diff --git a/libs/guessit/test/rules/episodes.yml b/libs/guessit/test/rules/episodes.yml index a75e6702..44e06a3b 100644 --- a/libs/guessit/test/rules/episodes.yml +++ b/libs/guessit/test/rules/episodes.yml @@ -32,8 +32,6 @@ ? +serie Season 2 other ? +serie Saisons 2 other ? +serie Seasons 2 other -? +serie Serie 2 other -? +serie Series 2 other ? +serie Season Two other ? +serie Season II other : season: 2 @@ -116,10 +114,15 @@ ? -A very special movie : episode_details: Special -? A very special episode +? -A very special episode : options: -t episode episode_details: Special +? A very special episode s06 special +: options: -t episode + title: A very special episode + episode_details: Special + ? 12 Monkeys\Season 01\Episode 05\12 Monkeys - S01E05 - The Night Room.mkv : container: mkv title: 12 Monkeys @@ -141,7 +144,7 @@ ? Show.Name.-.Season.1.to.3.-.Mp4.1080p ? Show.Name.-.Season.1~3.-.Mp4.1080p ? Show.Name.-.Saison.1.a.3.-.Mp4.1080p -: container: MP4 +: container: mp4 screen_size: 1080p season: - 1 @@ -151,7 +154,7 @@ ? Show.Name.Season.1.3&5.HDTV.XviD-GoodGroup[SomeTrash] ? Show.Name.Season.1.3 and 5.HDTV.XviD-GoodGroup[SomeTrash] -: format: HDTV +: source: HDTV release_group: GoodGroup[SomeTrash] season: - 1 @@ -159,12 +162,12 @@ - 5 title: Show Name type: episode - video_codec: XviD + video_codec: Xvid ? Show.Name.Season.1.2.3-5.HDTV.XviD-GoodGroup[SomeTrash] ? Show.Name.Season.1.2.3~5.HDTV.XviD-GoodGroup[SomeTrash] ? Show.Name.Season.1.2.3 to 5.HDTV.XviD-GoodGroup[SomeTrash] -: format: HDTV +: source: HDTV release_group: GoodGroup[SomeTrash] season: - 1 @@ -174,18 +177,19 @@ - 5 title: Show Name type: episode - video_codec: XviD + video_codec: Xvid ? The.Get.Down.S01EP01.FRENCH.720p.WEBRIP.XVID-STR : episode: 1 - format: WEBRip + source: Web + other: Rip language: fr release_group: STR screen_size: 720p season: 1 title: The Get Down type: episode - video_codec: XviD + video_codec: Xvid ? My.Name.Is.Earl.S01E01-S01E21.SWE-SUB : episode: @@ -244,4 +248,84 @@ ? epi : options: -t episode - title: epi \ No newline at end of file + title: epi + +? Episode20 +? Episode 20 +: episode: 20 + +? Episode50 +? Episode 50 +: episode: 50 + +? Episode51 +? Episode 51 +: episode: 51 + +? Episode70 +? Episode 70 +: episode: 70 + +? Episode71 +? Episode 71 +: episode: 71 + +? S01D02.3-5-GROUP +: disc: [2, 3, 4, 5] + +? S01D02&4-6&8 +: disc: [2, 4, 5, 6, 8] + +? Something.4x05-06 +? Something - 4x05-06 +? Something:4x05-06 +? Something 4x05-06 +? Something-4x05-06 +: title: Something + season: 4 + episode: + - 5 + - 6 + +? Something.4x05-06 +? Something - 4x05-06 +? Something:4x05-06 +? Something 4x05-06 +? Something-4x05-06 +: options: -T something + title: something + season: 4 + episode: + - 5 + - 6 + +? Colony 23/S01E01.Some.title.mkv +: title: Colony 23 + season: 1 + episode: 1 + episode_title: Some title + +? Show.Name.E02.2010.mkv +: options: -t episode + title: Show Name + year: 2010 + episode: 2 + +? Show.Name.E02.S2010.mkv +: options: -t episode + title: Show Name + year: 2010 + season: 2010 + episode: 2 + + +? Show.Name.E02.2010.mkv +: title: Show Name + year: 2010 + episode: 2 + +? Show.Name.E02.S2010.mkv +: title: Show Name + year: 2010 + season: 2010 + episode: 2 diff --git a/libs/guessit/test/rules/format.yml b/libs/guessit/test/rules/format.yml deleted file mode 100644 index cf3dea92..00000000 --- a/libs/guessit/test/rules/format.yml +++ /dev/null @@ -1,112 +0,0 @@ -# Multiple input strings having same expected results can be chained. -# Use - marker to check inputs that should not match results. -? +VHS -? +VHSRip -? +VHS-Rip -? +VhS_rip -? +VHS.RIP -? -VHSAnythingElse -? -SomeVHS stuff -? -VH -? -VHx -? -VHxRip -: format: VHS - -? +Cam -? +CamRip -? +CaM Rip -? +Cam_Rip -? +cam.rip -: format: Cam - -? +Telesync -? +TS -? +HD TS -? -Hd.Ts # ts file extension -? -HD.TS # ts file extension -? +Hd-Ts -: format: Telesync - -? +Workprint -? +workPrint -? +WorkPrint -? +WP -? -Work Print -: format: Workprint - -? +Telecine -? +teleCine -? +TC -? -Tele Cine -: format: Telecine - -? +PPV -? +ppv-rip -: format: PPV - -? -TV -? +SDTV -? +SDTVRIP -? +Rip sd tv -? +TvRip -? +Rip TV -: format: TV - -? +DVB -? +DVB-Rip -? +DvBRiP -? +pdTV -? +Pd Tv -: format: DVB - -? +DVD -? +DVD-RIP -? +video ts -? +DVDR -? +DVD 9 -? +dvd 5 -? -dvd ts -: format: DVD - -format: ts - -? +HDTV -? +tv rip hd -? +HDtv Rip -? +HdRip -: format: HDTV - -? +VOD -? +VodRip -? +vod rip -: format: VOD - -? +webrip -? +Web Rip -: format: WEBRip - -? +webdl -? +Web DL -? +webHD -? +WEB hd -? +web -: format: WEB-DL - -? +HDDVD -? +hd dvd -? +hdDvdRip -: format: HD-DVD - -? +BluRay -? +BluRay rip -? +BD -? +BR -? +BDRip -? +BR rip -? +BD5 -? +BD9 -? +BD25 -? +bd50 -: format: BluRay - -? XVID.NTSC.DVDR.nfo -: format: DVD diff --git a/libs/guessit/test/rules/language.yml b/libs/guessit/test/rules/language.yml index 51bbd8da..10e5b9c0 100644 --- a/libs/guessit/test/rules/language.yml +++ b/libs/guessit/test/rules/language.yml @@ -36,4 +36,12 @@ ? +ENG.-.SubSV ? +ENG.-.SVSUB : language: English - subtitle_language: Swedish \ No newline at end of file + subtitle_language: Swedish + +? The English Patient (1996) +: title: The English Patient + -language: english + +? French.Kiss.1995.1080p +: title: French Kiss + -language: french diff --git a/libs/guessit/test/rules/other.yml b/libs/guessit/test/rules/other.yml index cce8cbd0..e2bea6e7 100644 --- a/libs/guessit/test/rules/other.yml +++ b/libs/guessit/test/rules/other.yml @@ -12,42 +12,35 @@ ? +AudioFixed ? +Audio Fix ? +Audio Fixed -: other: AudioFix +: other: Audio Fixed ? +SyncFix ? +SyncFixed ? +Sync Fix ? +Sync Fixed -: other: SyncFix +: other: Sync Fixed ? +DualAudio ? +Dual Audio -: other: DualAudio +: other: Dual Audio ? +ws ? +WideScreen ? +Wide Screen -: other: WideScreen +: other: Widescreen -? +NF -? +Netflix -: other: Netflix - -# Fix and Real must be surround by others properties to be matched. -? DVD.Real.XViD +# Fix must be surround by others properties to be matched. ? DVD.fix.XViD -? -DVD.Real ? -DVD.Fix -? -Real.XViD ? -Fix.XViD -: other: Proper - proper_count: 1 +: other: Fix + -proper_count: 1 ? -DVD.BlablaBla.Fix.Blablabla.XVID ? -DVD.BlablaBla.Fix.XVID ? -DVD.Fix.Blablabla.XVID -: other: Proper - proper_count: 1 +: other: Fix + -proper_count: 1 ? DVD.Real.PROPER.REPACK @@ -62,18 +55,20 @@ proper_count: 1 ? XViD.Fansub -: other: Fansub +: other: Fan Subtitled ? XViD.Fastsub -: other: Fastsub +: other: Fast Subtitled ? +Season Complete ? -Complete : other: Complete ? R5 +: other: Region 5 + ? RC -: other: R5 +: other: Region C ? PreAir ? Pre Air @@ -91,20 +86,26 @@ ? HD : other: HD -? mHD # ?? -: other: mHD +? FHD +? FullHD +? Full HD +: other: Full HD +? UHD +? Ultra +? UltraHD +? Ultra HD +: other: Ultra HD + +? mHD # ?? ? HDLight -: other: HDLight +: other: Micro HD ? HQ -: other: HQ - -? ddc -: other: DDC +: other: High Quality ? hr -: other: HR +: other: High Resolution ? PAL : other: PAL @@ -115,14 +116,14 @@ ? NTSC : other: NTSC -? CC -: other: CC +? LDTV +: other: Low Definition ? LD -: other: LD +: other: Line Dubbed ? MD -: other: MD +: other: Mic Dubbed ? -The complete movie : other: Complete @@ -131,7 +132,38 @@ : title: The complete movie ? +AC3-HQ -: audio_profile: HQ +: audio_profile: High Quality ? Other-HQ -: other: HQ +: other: High Quality + +? reenc +? re-enc +? re-encoded +? reencoded +: other: Reencoded + +? CONVERT XViD +: other: Converted + +? +HDRIP # it's a Rip from non specified HD source +: other: [HD, Rip] + +? SDR +: other: Standard Dynamic Range + +? HDR +? HDR10 +? -HDR100 +: other: HDR10 + +? BT2020 +? BT.2020 +? -BT.20200 +? -BT.2021 +: other: BT.2020 + +? Upscaled +? Upscale +: other: Upscaled + diff --git a/libs/guessit/test/rules/processors_test.py b/libs/guessit/test/rules/processors_test.py new file mode 100644 index 00000000..c22e968c --- /dev/null +++ b/libs/guessit/test/rules/processors_test.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, pointless-string-statement + +from rebulk.match import Matches, Match + +from ...rules.processors import StripSeparators + + +def test_strip_separators(): + strip_separators = StripSeparators() + + matches = Matches() + + m = Match(3, 11, input_string="pre.ABCDEF.post") + + assert m.raw == '.ABCDEF.' + matches.append(m) + + returned_matches = strip_separators.when(matches, None) + assert returned_matches == matches + + strip_separators.then(matches, returned_matches, None) + + assert m.raw == 'ABCDEF' + + +def test_strip_separators_keep_acronyms(): + strip_separators = StripSeparators() + + matches = Matches() + + m = Match(0, 13, input_string=".S.H.I.E.L.D.") + m2 = Match(0, 22, input_string=".Agent.Of.S.H.I.E.L.D.") + + assert m.raw == '.S.H.I.E.L.D.' + matches.append(m) + matches.append(m2) + + returned_matches = strip_separators.when(matches, None) + assert returned_matches == matches + + strip_separators.then(matches, returned_matches, None) + + assert m.raw == '.S.H.I.E.L.D.' + assert m2.raw == 'Agent.Of.S.H.I.E.L.D.' diff --git a/libs/guessit/test/rules/release_group.yml b/libs/guessit/test/rules/release_group.yml index d048ff71..c96383e9 100644 --- a/libs/guessit/test/rules/release_group.yml +++ b/libs/guessit/test/rules/release_group.yml @@ -39,3 +39,33 @@ season: 1 title: Test type: episode + +? Show.Name.x264-byEMP +: title: Show Name + video_codec: H.264 + release_group: byEMP + +? Show.Name.x264-NovaRip +: title: Show Name + video_codec: H.264 + release_group: NovaRip + +? Show.Name.x264-PARTiCLE +: title: Show Name + video_codec: H.264 + release_group: PARTiCLE + +? Show.Name.x264-POURMOi +: title: Show Name + video_codec: H.264 + release_group: POURMOi + +? Show.Name.x264-RipPourBox +: title: Show Name + video_codec: H.264 + release_group: RipPourBox + +? Show.Name.x264-RiPRG +: title: Show Name + video_codec: H.264 + release_group: RiPRG diff --git a/libs/guessit/test/rules/screen_size.yml b/libs/guessit/test/rules/screen_size.yml index 1145dd7e..25d8374f 100644 --- a/libs/guessit/test/rules/screen_size.yml +++ b/libs/guessit/test/rules/screen_size.yml @@ -2,68 +2,279 @@ # Use - marker to check inputs that should not match results. ? +360p ? +360px -? +360i -? "+360" +? -360 ? +500x360 +? -250x360 : screen_size: 360p +? +640x360 +? -640x360i +? -684x360i +: screen_size: 360p + aspect_ratio: 1.778 + +? +360i +: screen_size: 360i + +? +480x360i +? -480x360p +? -450x360 +: screen_size: 360i + aspect_ratio: 1.333 + ? +368p ? +368px -? +368i -? "+368" +? -368i +? -368 ? +500x368 : screen_size: 368p +? -490x368 +? -700x368 +: screen_size: 368p + +? +492x368p +: screen_size: + aspect_ratio: 1.337 + +? +654x368 +: screen_size: 368p + aspect_ratio: 1.777 + +? +698x368 +: screen_size: 368p + aspect_ratio: 1.897 + +? +368i +: -screen_size: 368i + ? +480p ? +480px -? +480i -? "+480" -? +500x480 +? -480i +? -480 +? -500x480 +? -638x480 +? -920x480 : screen_size: 480p +? +640x480 +: screen_size: 480p + aspect_ratio: 1.333 + +? +852x480 +: screen_size: 480p + aspect_ratio: 1.775 + +? +910x480 +: screen_size: 480p + aspect_ratio: 1.896 + +? +500x480 +? +500 x 480 +? +500 * 480 +? +500x480p +? +500X480i +: screen_size: 500x480 + aspect_ratio: 1.042 + +? +480i +? +852x480i +: screen_size: 480i + ? +576p ? +576px -? +576i -? "+576" -? +500x576 +? -576i +? -576 +? -500x576 +? -766x576 +? -1094x576 : screen_size: 576p +? +768x576 +: screen_size: 576p + aspect_ratio: 1.333 + +? +1024x576 +: screen_size: 576p + aspect_ratio: 1.778 + +? +1092x576 +: screen_size: 576p + aspect_ratio: 1.896 + +? +500x576 +: screen_size: 500x576 + aspect_ratio: 0.868 + +? +576i +: screen_size: 576i + ? +720p ? +720px +? -720i ? 720hd ? 720pHD -? +720i -? "+720" -? +500x720 +? -720 +? -500x720 +? -950x720 +? -1368x720 : screen_size: 720p +? +960x720 +: screen_size: 720p + aspect_ratio: 1.333 + +? +1280x720 +: screen_size: 720p + aspect_ratio: 1.778 + +? +1366x720 +: screen_size: 720p + aspect_ratio: 1.897 + +? +500x720 +: screen_size: 500x720 + aspect_ratio: 0.694 + ? +900p ? +900px -? +900i -? "+900" -? +500x900 +? -900i +? -900 +? -500x900 +? -1198x900 +? -1710x900 : screen_size: 900p +? +1200x900 +: screen_size: 900p + aspect_ratio: 1.333 + +? +1600x900 +: screen_size: 900p + aspect_ratio: 1.778 + +? +1708x900 +: screen_size: 900p + aspect_ratio: 1.898 + +? +500x900 +? +500x900p +? +500x900i +: screen_size: 500x900 + aspect_ratio: 0.556 + +? +900i +: screen_size: 900i + ? +1080p ? +1080px ? +1080hd ? +1080pHD ? -1080i -? "+1080" -? +500x1080 +? -1080 +? -500x1080 +? -1438x1080 +? -2050x1080 : screen_size: 1080p +? +1440x1080 +: screen_size: 1080p + aspect_ratio: 1.333 + +? +1920x1080 +: screen_size: 1080p + aspect_ratio: 1.778 + +? +2048x1080 +: screen_size: 1080p + aspect_ratio: 1.896 + ? +1080i ? -1080p : screen_size: 1080i +? 1440p +: screen_size: 1440p + +? +500x1080 +: screen_size: 500x1080 + aspect_ratio: 0.463 + ? +2160p ? +2160px -? +2160i -? "+2160" +? -2160i +? -2160 ? +4096x2160 -: screen_size: 4K +? +4k +? -2878x2160 +? -4100x2160 +: screen_size: 2160p + +? +2880x2160 +: screen_size: 2160p + aspect_ratio: 1.333 + +? +3840x2160 +: screen_size: 2160p + aspect_ratio: 1.778 + +? +4098x2160 +: screen_size: 2160p + aspect_ratio: 1.897 + +? +500x2160 +: screen_size: 500x2160 + aspect_ratio: 0.231 + +? +4320p +? +4320px +? -4320i +? -4320 +? -5758x2160 +? -8198x2160 +: screen_size: 4320p + +? +5760x4320 +: screen_size: 4320p + aspect_ratio: 1.333 + +? +7680x4320 +: screen_size: 4320p + aspect_ratio: 1.778 + +? +8196x4320 +: screen_size: 4320p + aspect_ratio: 1.897 + +? +500x4320 +: screen_size: 500x4320 + aspect_ratio: 0.116 ? Test.File.720hd.bluray +? Test.File.720p24 +? Test.File.720p30 ? Test.File.720p50 +? Test.File.720p60 +? Test.File.720p120 : screen_size: 720p + +? Test.File.400p +: options: + advanced_config: + screen_size: + progressive: ["400"] + screen_size: 400p + +? Test.File2.400p +: options: + advanced_config: + screen_size: + progressive: ["400"] + screen_size: 400p + +? Test.File.720p +: options: + advanced_config: + screen_size: + progressive: ["400"] + screen_size: 720p diff --git a/libs/guessit/test/rules/size.yml b/libs/guessit/test/rules/size.yml new file mode 100644 index 00000000..18b3cd49 --- /dev/null +++ b/libs/guessit/test/rules/size.yml @@ -0,0 +1,8 @@ +? 1.1tb +: size: 1.1TB + +? 123mb +: size: 123MB + +? 4.3gb +: size: 4.3GB diff --git a/libs/guessit/test/rules/source.yml b/libs/guessit/test/rules/source.yml new file mode 100644 index 00000000..cda8f1ac --- /dev/null +++ b/libs/guessit/test/rules/source.yml @@ -0,0 +1,323 @@ +# Multiple input strings having same expected results can be chained. +# Use - marker to check inputs that should not match results. +? +VHS +? -VHSAnythingElse +? -SomeVHS stuff +? -VH +? -VHx +: source: VHS + -other: Rip + +? +VHSRip +? +VHS-Rip +? +VhS_rip +? +VHS.RIP +? -VHS +? -VHxRip +: source: VHS + other: Rip + +? +Cam +: source: Camera + -other: Rip + +? +CamRip +? +CaM Rip +? +Cam_Rip +? +cam.rip +? -Cam +: source: Camera + other: Rip + +? +HDCam +? +HD-Cam +: source: HD Camera + -other: Rip + +? +HDCamRip +? +HD-Cam.rip +? -HDCam +? -HD-Cam +: source: HD Camera + other: Rip + +? +Telesync +? +TS +: source: Telesync + -other: Rip + +? +TelesyncRip +? +TSRip +? -Telesync +? -TS +: source: Telesync + other: Rip + +? +HD TS +? -Hd.Ts # ts file extension +? -HD.TS # ts file extension +? +Hd-Ts +: source: HD Telesync + -other: Rip + +? +HD TS Rip +? +Hd-Ts-Rip +? -HD TS +? -Hd-Ts +: source: HD Telesync + other: Rip + +? +Workprint +? +workPrint +? +WorkPrint +? +WP +? -Work Print +: source: Workprint + -other: Rip + +? +Telecine +? +teleCine +? +TC +? -Tele Cine +: source: Telecine + -other: Rip + +? +Telecine Rip +? +teleCine-Rip +? +TC-Rip +? -Telecine +? -TC +: source: Telecine + other: Rip + +? +HD-TELECINE +? +HDTC +: source: HD Telecine + -other: Rip + +? +HD-TCRip +? +HD TELECINE RIP +? -HD-TELECINE +? -HDTC +: source: HD Telecine + other: Rip + +? +PPV +: source: Pay-per-view + -other: Rip + +? +ppv-rip +? -PPV +: source: Pay-per-view + other: Rip + +? -TV +? +SDTV +? +TV-Dub +: source: TV + -other: Rip + +? +SDTVRIP +? +Rip sd tv +? +TvRip +? +Rip TV +? -TV +? -SDTV +: source: TV + other: Rip + +? +DVB +? +pdTV +? +Pd Tv +: source: Digital TV + -other: Rip + +? +DVB-Rip +? +DvBRiP +? +pdtvRiP +? +pd tv RiP +? -DVB +? -pdTV +? -Pd Tv +: source: Digital TV + other: Rip + +? +DVD +? +video ts +? +DVDR +? +DVD 9 +? +dvd 5 +? -dvd ts +: source: DVD + -source: Telesync + -other: Rip + +? +DVD-RIP +? -video ts +? -DVD +? -DVDR +? -DVD 9 +? -dvd 5 +: source: DVD + other: Rip + +? +HDTV +: source: HDTV + -other: Rip + +? +tv rip hd +? +HDtv Rip +? -HdRip # it's a Rip from non specified HD source +? -HDTV +: source: HDTV + other: Rip + +? +VOD +: source: Video on Demand + -other: Rip + +? +VodRip +? +vod rip +? -VOD +: source: Video on Demand + other: Rip + +? +webrip +? +Web Rip +? +webdlrip +? +web dl rip +? +webcap +? +web cap +? +webcaprip +? +web cap rip +: source: Web + other: Rip + +? +webdl +? +Web DL +? +webHD +? +WEB hd +? +web +: source: Web + -other: Rip + +? +HDDVD +? +hd dvd +: source: HD-DVD + -other: Rip + +? +hdDvdRip +? -HDDVD +? -hd dvd +: source: HD-DVD + other: Rip + +? +BluRay +? +BD +? +BD5 +? +BD9 +? +BD25 +? +bd50 +: source: Blu-ray + -other: Rip + +? +BR-Scr +? +BR.Screener +: source: Blu-ray + other: [Reencoded, Screener] + -language: pt-BR + +? +BR-Rip +? +BRRip +: source: Blu-ray + other: [Reencoded, Rip] + -language: pt-BR + +? +BluRay rip +? +BDRip +? -BluRay +? -BD +? -BR +? -BR rip +? -BD5 +? -BD9 +? -BD25 +? -bd50 +: source: Blu-ray + other: Rip + +? XVID.NTSC.DVDR.nfo +: source: DVD + -other: Rip + +? +AHDTV +: source: Analog HDTV + -other: Rip + +? +dsr +? +dth +: source: Satellite + -other: Rip + +? +dsrip +? +ds rip +? +dsrrip +? +dsr rip +? +satrip +? +sat rip +? +dthrip +? +dth rip +? -dsr +? -dth +: source: Satellite + other: Rip + +? +UHDTV +: source: Ultra HDTV + -other: Rip + +? +UHDRip +? +UHDTV Rip +? -UHDTV +: source: Ultra HDTV + other: Rip + +? UHD Bluray +? UHD 2160p Bluray +? UHD 8bit Bluray +? UHD HQ 8bit Bluray +? Ultra Bluray +? Ultra HD Bluray +? Bluray ULTRA +? Bluray Ultra HD +? Bluray UHD +? 4K Bluray +? 2160p Bluray +? UHD 10bit HDR Bluray +? UHD HDR10 Bluray +? -HD Bluray +? -AMERICAN ULTRA (2015) 1080p Bluray +? -American.Ultra.2015.BRRip +? -BRRip XviD AC3-ULTRAS +? -UHD Proper Bluray +: source: Ultra HD Blu-ray + +? UHD.BRRip +? UHD.2160p.BRRip +? BRRip.2160p.UHD +? BRRip.[4K-2160p-UHD] +: source: Ultra HD Blu-ray + other: [Reencoded, Rip] + +? UHD.2160p.BDRip +? BDRip.[4K-2160p-UHD] +: source: Ultra HD Blu-ray + other: Rip + +? DM +: source: Digital Master + +? DMRIP +? DM-RIP +: source: Digital Master + other: Rip diff --git a/libs/guessit/test/rules/title.yml b/libs/guessit/test/rules/title.yml index fffaf8a2..05c7f208 100644 --- a/libs/guessit/test/rules/title.yml +++ b/libs/guessit/test/rules/title.yml @@ -30,3 +30,14 @@ ? Some.Other title/Some other title.mkv : title: Some Other title +? This T.I.T.L.E. has dots +? This.T.I.T.L.E..has.dots +: title: This T.I.T.L.E has dots + +? This.T.I.T.L.E..has.dots.S01E02.This E.P.T.I.T.L.E.has.dots +: title: This T.I.T.L.E has dots + season: 1 + episode: 2 + episode_title: This E.P.T.I.T.L.E has dots + type: episode + diff --git a/libs/guessit/test/rules/video_codec.yml b/libs/guessit/test/rules/video_codec.yml index d195eaaf..ae43bc43 100644 --- a/libs/guessit/test/rules/video_codec.yml +++ b/libs/guessit/test/rules/video_codec.yml @@ -6,15 +6,19 @@ ? Rv30 ? rv40 ? -xrv40 -: video_codec: Real +: video_codec: RealVideo ? mpeg2 ? MPEG2 +? MPEG-2 +? mpg2 +? H262 +? H.262 +? x262 ? -mpeg -? -mpeg 2 # Not sure if we should ignore this one ... ? -xmpeg2 ? -mpeg2x -: video_codec: Mpeg2 +: video_codec: MPEG-2 ? DivX ? -div X @@ -26,19 +30,25 @@ ? XviD ? xvid ? -x vid -: video_codec: XviD +: video_codec: Xvid + +? h263 +? x263 +? h.263 +: video_codec: H.263 ? h264 ? x264 ? h.264 ? x.264 -? mpeg4-AVC +? AVC +? AVCHD ? -MPEG-4 ? -mpeg4 ? -mpeg ? -h 265 ? -x265 -: video_codec: h264 +: video_codec: H.264 ? h265 ? x265 @@ -47,8 +57,42 @@ ? hevc ? -h 264 ? -x264 -: video_codec: h265 +: video_codec: H.265 + +? hevc10 +? HEVC-YUV420P10 +: video_codec: H.265 + color_depth: 10-bit ? h265-HP -: video_codec: h265 - video_profile: HP \ No newline at end of file +: video_codec: H.265 + video_profile: High + +? H.264-SC +: video_codec: H.264 + video_profile: Scalable Video Coding + +? mpeg4-AVC +: video_codec: H.264 + video_profile: Advanced Video Codec High Definition + +? AVCHD-SC +? H.264-AVCHD-SC +: video_codec: H.264 + video_profile: + - Scalable Video Coding + - Advanced Video Codec High Definition + +? VC1 +? VC-1 +: video_codec: VC-1 + +? VP7 +: video_codec: VP7 + +? VP8 +? VP80 +: video_codec: VP8 + +? VP9 +: video_codec: VP9 diff --git a/libs/guessit/test/streaming_services.yaml b/libs/guessit/test/streaming_services.yaml new file mode 100644 index 00000000..adf52e71 --- /dev/null +++ b/libs/guessit/test/streaming_services.yaml @@ -0,0 +1,1934 @@ +? House.of.Cards.2013.S02E03.1080p.NF.WEBRip.DD5.1.x264-NTb.mkv +? House.of.Cards.2013.S02E03.1080p.Netflix.WEBRip.DD5.1.x264-NTb.mkv +: title: House of Cards + year: 2013 + season: 2 + episode: 3 + screen_size: 1080p + streaming_service: Netflix + source: Web + other: Rip + audio_channels: "5.1" + audio_codec: Dolby Digital + video_codec: H.264 + release_group: NTb + +? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.720p.CC.WEBRip.AAC2.0.x264-BTW.mkv +? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.720p.ComedyCentral.WEBRip.AAC2.0.x264-BTW.mkv +? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.720p.Comedy.Central.WEBRip.AAC2.0.x264-BTW.mkv +: audio_channels: '2.0' + audio_codec: AAC + date: 2015-07-01 + edition: Extended + source: Web + other: Rip + release_group: BTW + screen_size: 720p + streaming_service: Comedy Central + title: The Daily Show + episode_title: Kirsten Gillibrand + video_codec: H.264 + +? The.Daily.Show.2015.07.01.Kirsten.Gillibrand.Extended.Interview.720p.CC.WEBRip.AAC2.0.x264-BTW.mkv +: audio_channels: '2.0' + audio_codec: AAC + date: 2015-07-01 + source: Web + release_group: BTW + screen_size: 720p + streaming_service: Comedy Central + title: The Daily Show + episode_title: Kirsten Gillibrand Extended Interview + video_codec: H.264 + +? The.Daily.Show.2015.07.02.Sarah.Vowell.CC.WEBRip.AAC2.0.x264-BTW.mkv +: audio_channels: '2.0' + audio_codec: AAC + date: 2015-07-02 + source: Web + release_group: BTW + streaming_service: Comedy Central + title: The Daily Show + episode_title: Sarah Vowell + video_codec: H.264 + +# Streaming service: Amazon +? Show.Name.S07E04.Service.1080p.AMZN.WEBRip.DD+5.1.x264 +? Show.Name.S07E04.Service.1080p.AmazonPrime.WEBRip.DD+5.1.x264 +: title: Show Name + season: 7 + episode: 4 + episode_title: Service + screen_size: 1080p + streaming_service: Amazon Prime + source: Web + other: Rip + audio_codec: Dolby Digital Plus + audio_channels: '5.1' + video_codec: H.264 + type: episode + +# Streaming service: Comedy Central +? Show.Name.2016.09.28.Nice.Title.Extended.1080p.CC.WEBRip.AAC2.0.x264-monkee +: title: Show Name + date: 2016-09-28 + episode_title: Nice Title + edition: Extended + other: Rip + screen_size: 1080p + streaming_service: Comedy Central + source: Web + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: The CW +? Show.Name.US.S12E20.Nice.Title.720p.CW.WEBRip.AAC2.0.x264-monkee +? Show.Name.US.S12E20.Nice.Title.720p.TheCW.WEBRip.AAC2.0.x264-monkee +: title: Show Name + country: US + season: 12 + episode: 20 + episode_title: Nice Title + screen_size: 720p + streaming_service: The CW + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: AMBC +? Show.Name.2016.09.27.Nice.Title.720p.AMBC.WEBRip.AAC2.0.x264-monkee +: title: Show Name + date: 2016-09-27 + episode_title: Nice Title + screen_size: 720p + streaming_service: ABC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: HIST +? Show.Name.720p.HIST.WEBRip.AAC2.0.H.264-monkee +? Show.Name.720p.History.WEBRip.AAC2.0.H.264-monkee +: options: -t episode + title: Show Name + screen_size: 720p + streaming_service: History + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: PBS +? Show.Name.2015.Nice.Title.1080p.PBS.WEBRip.AAC2.0.H264-monkee +: options: -t episode + title: Show Name + year: 2015 + episode_title: Nice Title + screen_size: 1080p + streaming_service: PBS + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: SeeSo +? Show.Name.2016.Nice.Title.1080p.SESO.WEBRip.AAC2.0.x264-monkee +: options: -t episode + title: Show Name + year: 2016 + episode_title: Nice Title + screen_size: 1080p + streaming_service: SeeSo + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Discovery +? Show.Name.S01E03.Nice.Title.720p.DISC.WEBRip.AAC2.0.x264-NTb +? Show.Name.S01E03.Nice.Title.720p.Discovery.WEBRip.AAC2.0.x264-NTb +: title: Show Name + season: 1 + episode: 3 + episode_title: Nice Title + screen_size: 720p + streaming_service: Discovery + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: NTb + type: episode + +# Streaming service: BBC iPlayer +? Show.Name.2016.08.18.Nice.Title.720p.iP.WEBRip.AAC2.0.H.264-monkee +? Show.Name.2016.08.18.Nice.Title.720p.BBCiPlayer.WEBRip.AAC2.0.H.264-monkee +: title: Show Name + date: 2016-08-18 + episode_title: Nice Title + streaming_service: BBC iPlayer + screen_size: 720p + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: A&E +? Show.Name.S15E18.Nice.Title.720p.AE.WEBRip.AAC2.0.H.264-monkee +? Show.Name.S15E18.Nice.Title.720p.A&E.WEBRip.AAC2.0.H.264-monkee +: title: Show Name + season: 15 + episode: 18 + episode_title: Nice Title + screen_size: 720p + streaming_service: A&E + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Adult Swim +? Show.Name.S04E01.Nice.Title.1080p.AS.WEBRip.AAC2.0.H.264-monkee +? Show.Name.S04E01.Nice.Title.1080p.AdultSwim.WEBRip.AAC2.0.H.264-monkee +: title: Show Name + season: 4 + episode: 1 + episode_title: Nice Title + screen_size: 1080p + streaming_service: Adult Swim + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Netflix +? Show.Name.2013.S02E03.1080p.NF.WEBRip.DD5.1.x264-NTb.mkv +: title: Show Name + year: 2013 + season: 2 + episode: 3 + screen_size: 1080p + streaming_service: Netflix + source: Web + other: Rip + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: NTb + container: mkv + type: episode + +# Streaming service: CBS +? Show.Name.2016.05.10.Nice.Title.720p.CBS.WEBRip.AAC2.0.x264-monkee +: title: Show Name + date: 2016-05-10 + episode_title: Nice Title + screen_size: 720p + streaming_service: CBS + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: NBA TV +? NBA.2016.02.27.Team.A.vs.Team.B.720p.NBA.WEBRip.AAC2.0.H.264-monkee +? NBA.2016.02.27.Team.A.vs.Team.B.720p.NBATV.WEBRip.AAC2.0.H.264-monkee +: title: NBA + date: 2016-02-27 + episode_title: Team A vs Team B + screen_size: 720p + streaming_service: NBA TV + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: ePix +? Show.Name.S05E04.Nice.Title.Part4.720p.EPIX.WEBRip.AAC2.0.H.264-monkee +? Show.Name.S05E04.Nice.Title.Part4.720p.ePix.WEBRip.AAC2.0.H.264-monkee +: title: Show Name + season: 5 + episode: 4 + episode_title: Nice Title + part: 4 + screen_size: 720p + streaming_service: ePix + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: NBC +? Show.Name.S41E03.Nice.Title.720p.NBC.WEBRip.AAC2.0.x264-monkee +: title: Show Name + season: 41 + episode: 3 + episode_title: Nice Title + screen_size: 720p + streaming_service: NBC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Syfy +? Show.Name.S01E02.Nice.Title.720p.SYFY.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.Syfy.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: Syfy + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: Spike TV +? Show.Name.S01E02.Nice.Title.720p.SPKE.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.Spike TV.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.SpikeTV.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: Spike TV + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: IFC +? Show.Name.S01E02.Nice.Title.720p.IFC.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: IFC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: NATG +? Show.Name.S01E02.Nice.Title.720p.NATG.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.NationalGeographic.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: National Geographic + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: NFL +? Show.Name.S01E02.Nice.Title.720p.NFL.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: NFL + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: UFC +? Show.Name.S01E02.Nice.Title.720p.UFC.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: UFC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: TV Land +? Show.Name.S01E02.Nice.Title.720p.TVL.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.TVLand.WEBRip.AAC2.0.x264-group +? Show.Name.S01E02.Nice.Title.720p.TV Land.WEBRip.AAC2.0.x264-group +: title: Show Name + season: 1 + episode: 2 + episode_title: Nice Title + screen_size: 720p + streaming_service: TV Land + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: group + type: episode + +# Streaming service: Crunchy Roll +? Show.Name.S01.1080p.CR.WEBRip.AAC.2.0.x264-monkee +: title: Show Name + season: 1 + screen_size: 1080p + streaming_service: Crunchy Roll + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Disney +? Show.Name.S01.1080p.DSNY.WEBRip.AAC.2.0.x264-monkee +? Show.Name.S01.1080p.Disney.WEBRip.AAC.2.0.x264-monkee +: title: Show Name + season: 1 + screen_size: 1080p + streaming_service: Disney + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: Nickelodeon +? Show.Name.S01.1080p.NICK.WEBRip.AAC.2.0.x264-monkee +? Show.Name.S01.1080p.Nickelodeon.WEBRip.AAC.2.0.x264-monkee +: title: Show Name + season: 1 + screen_size: 1080p + streaming_service: Nickelodeon + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: TFou +? Show.Name.S01.1080p.TFOU.WEBRip.AAC.2.0.x264-monkee +? Show.Name.S01.1080p.TFou.WEBRip.AAC.2.0.x264-monkee +: title: Show Name + season: 1 + screen_size: 1080p + streaming_service: TFou + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: monkee + type: episode + +# Streaming service: DIY Network +? Show.Name.S01.720p.DIY.WEBRip.AAC2.0.H.264-BTN +: title: Show Name + season: 1 + screen_size: 720p + streaming_service: DIY Network + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTN + type: episode + +# Streaming service: USA Network +? Show.Name.S01E02.Exfil.1080p.USAN.WEBRip.AAC2.0.x264-AJP69 +: title: Show Name + season: 1 + episode: 2 + screen_size: 1080p + streaming_service: USA Network + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: AJP69 + type: episode + +# Streaming service: TV3 Ireland +? Show.Name.S01E08.576p.TV3.WEBRip.AAC2.0.x264-HARiKEN +: title: Show Name + season: 1 + episode: 8 + screen_size: 576p + streaming_service: TV3 Ireland + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: HARiKEN + type: episode + +# Streaming service: TV4 Sweeden +? Show.Name.S05.720p.TV4.WEBRip.AAC2.0.H.264-BTW +: title: Show Name + season: 5 + screen_size: 720p + streaming_service: TV4 Sweeden + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTW + type: episode + +# Streaming service: TLC +? Show.Name.S02.720p.TLC.WEBRip.AAC2.0.x264-BTW +: title: Show Name + season: 2 + screen_size: 720p + streaming_service: TLC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTW + type: episode + +# Streaming service: Investigation Discovery +? Show.Name.S01E01.720p.ID.WEBRip.AAC2.0.x264-BTW +: title: Show Name + season: 1 + episode: 1 + screen_size: 720p + streaming_service: Investigation Discovery + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: BTW + type: episode + +# Streaming service: RTÉ One +? Show.Name.S10E01.576p.RTE.WEBRip.AAC2.0.H.264-RTN +: title: Show Name + season: 10 + episode: 1 + screen_size: 576p + streaming_service: RTÉ One + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: RTN + type: episode + +# Streaming service: AMC +? Show.Name.S01E01.1080p.AMC.WEBRip.H.264.AAC2.0-CasStudio +: title: Show Name + season: 1 + episode: 1 + screen_size: 1080p + streaming_service: AMC + source: Web + other: Rip + audio_codec: AAC + audio_channels: '2.0' + video_codec: H.264 + release_group: CasStudio + type: episode + +? Suits.S07E01.1080p.iT.WEB-DL.DD5.1.H.264-VLAD.mkv +? Suits.S07E01.1080p.iTunes.WEB-DL.DD5.1.H.264-VLAD.mkv +: title: Suits + season: 7 + episode: 1 + screen_size: 1080p + source: Web + streaming_service: iTunes + audio_codec: Dolby Digital + audio_channels: '5.1' + video_codec: H.264 + release_group: VLAD + container: mkv + type: episode + +? UpFront.S01.720p.AJAZ.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + season: 1 + source: Web + streaming_service: Al Jazeera English + title: UpFront + type: episode + video_codec: H.264 + +? Smack.The.Pony.S01.4OD.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + season: 1 + source: Web + streaming_service: Channel 4 + title: Smack The Pony + type: episode + video_codec: H.264 + +? The.Toy.Box.S01E01.720p.AMBC.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: BTN + screen_size: 720p + season: 1 + source: Web + streaming_service: ABC + title: The Toy Box + type: episode + video_codec: H.264 + +? Gundam.Reconguista.in.G.S01.720p.ANLB.WEBRip.AAC2.0.x264-HorribleSubs +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: HorribleSubs + screen_size: 720p + season: 1 + source: Web + streaming_service: AnimeLab + title: Gundam Reconguista in G + type: episode + video_codec: H.264 + +? Animal.Nation.with.Anthony.Anderson.S01E01.1080p.ANPL.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: RTN + screen_size: 1080p + season: 1 + source: Web + streaming_service: Animal Planet + title: Animal Nation with Anthony Anderson + type: episode + video_codec: H.264 + +? Park.Bench.S01.1080p.AOL.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 1080p + season: 1 + source: Web + streaming_service: AOL + title: Park Bench + type: episode + video_codec: H.264 + +? Crime.Scene.Cleaner.S05.720p.ARD.WEBRip.AAC2.0.H.264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + screen_size: 720p + season: 5 + source: Web + streaming_service: ARD + title: Crime Scene Cleaner + type: episode + video_codec: H.264 + +? Decker.S03.720p.AS.WEB-DL.AAC2.0.H.264-RTN +: audio_channels: '2.0' + audio_codec: AAC + release_group: RTN + screen_size: 720p + season: 3 + source: Web + streaming_service: Adult Swim + title: Decker + type: episode + video_codec: H.264 + +? Southern.Charm.Savannah.S01E04.Hurricane.On.The.Horizon.1080p.BRAV.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + episode: 4 + episode_title: Hurricane On The Horizon + other: Rip + release_group: BTW + screen_size: 1080p + season: 1 + source: Web + streaming_service: BravoTV + title: Southern Charm Savannah + type: episode + video_codec: H.264 + +? Four.in.the.Morning.S01E01.Pig.RERip.720p.CBC.WEBRip.AAC2.0.H.264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + episode_title: Pig + other: + - Proper + - Rip + proper_count: 1 + release_group: RTN + screen_size: 720p + season: 1 + source: Web + streaming_service: CBC + title: Four in the Morning + type: episode + video_codec: H.264 + +? Rio.Olympics.2016.08.07.Mens.Football.Group.C.Germany.vs.South.Korea.720p.CBC.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + date: 2016-08-07 + episode_title: Mens Football Group C Germany vs South Korea + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: CBC + title: Rio Olympics + type: episode + video_codec: H.264 + +? Comedians.In.Cars.Getting.Coffee.S01.720p.CCGC.WEBRip.AAC2.0.x264-monkee +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: monkee + screen_size: 720p + season: 1 + source: Web + streaming_service: Comedians in Cars Getting Coffee + title: Comedians In Cars Getting Coffee + type: episode + video_codec: H.264 + +? Life.on.Top.S02.720p.CMAX.WEBRip.AAC2.0.x264-CMAX +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: CMAX + screen_size: 720p + season: 2 + source: Web + streaming_service: Cinemax + title: Life on Top + type: episode + video_codec: H.264 + +? Sun.Records.S01.720p.CMT.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + season: 1 + source: Web + streaming_service: Country Music Television + title: Sun Records + type: episode + video_codec: H.264 + +? Infinity.Train.S01E00.Pilot.REPACK.720p.CN.WEBRip.AAC2.0.H.264-monkee +: audio_channels: '2.0' + audio_codec: AAC + episode: 0 + episode_details: Pilot + episode_title: Pilot + language: zh + other: + - Proper + - Rip + proper_count: 1 + release_group: monkee + screen_size: 720p + season: 1 + source: Web + streaming_service: Cartoon Network + title: Infinity Train + type: episode + video_codec: H.264 + +? Jay.Lenos.Garage.2015.S03E02.1080p.CNBC.WEB-DL.x264-TOPKEK +: episode: 2 + release_group: TOPKEK + screen_size: 1080p + season: 3 + source: Web + streaming_service: CNBC + title: Jay Lenos Garage + type: episode + video_codec: H.264 + year: 2015 + +? US.Presidential.Debates.2015.10.28.Third.Republican.Debate.720p.CNBC.WEBRip.AAC2.0.H.264-monkee +: audio_channels: '2.0' + audio_codec: AAC + country: US + date: 2015-10-28 + episode_title: Third Republican Debate + other: Rip + release_group: monkee + screen_size: 720p + source: Web + streaming_service: CNBC + title: Presidential Debates + type: episode + video_codec: H.264 + +? What.The.Fuck.France.S01E01.Le.doublage.CNLP.WEBRip.AAC2.0.x264-TURTLE +: audio_channels: '2.0' + audio_codec: AAC + country: FR + episode: 1 + episode_title: Le doublage + other: Rip + release_group: TURTLE + season: 1 + source: Web + streaming_service: Canal+ + title: What The Fuck + type: episode + video_codec: H.264 + +? SuperMansion.S02.720p.CRKL.WEBRip.AAC2.0.x264-VLAD +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: VLAD + screen_size: 720p + season: 2 + source: Web + streaming_service: Crackle + title: SuperMansion + type: episode + video_codec: H.264 + +? Chosen.S02.1080p.CRKL.WEBRip.AAC2.0.x264-AJP69 +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: AJP69 + screen_size: 1080p + season: 2 + source: Web + streaming_service: Crackle + title: Chosen + type: episode + video_codec: H.264 + +? Chosen.S03.1080p.CRKL.WEBRip.AAC2.0.x264-AJP69 +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: AJP69 + screen_size: 1080p + season: 3 + source: Web + streaming_service: Crackle + title: Chosen + type: episode + video_codec: H.264 + +? Snatch.S01.1080p.CRKL.WEBRip.AAC2.0.x264-DEFLATE +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: DEFLATE + screen_size: 1080p + season: 1 + source: Web + streaming_service: Crackle + title: Snatch + type: episode + video_codec: H.264 + +? White.House.Correspondents.Dinner.2015.Complete.CSPN.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: + - Complete + - Rip + release_group: BTW + source: Web + streaming_service: CSpan + title: White House Correspondents Dinner + type: movie + video_codec: H.264 + year: 2015 + +? The.Amazing.Race.Canada.S03.720p.CTV.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + country: CA + other: Rip + release_group: BTW + screen_size: 720p + season: 3 + source: Web + streaming_service: CTV + title: The Amazing Race + type: episode + video_codec: H.264 + +? Miniverse.S01E01.Explore.the.Solar.System.2160p.CUR.WEB-DL.DDP2.0.x264-monkee +: audio_channels: '2.0' + audio_codec: Dolby Digital Plus + episode: 1 + episode_title: Explore the Solar System + release_group: monkee + screen_size: 2160p + season: 1 + source: Web + streaming_service: CuriosityStream + title: Miniverse + type: episode + video_codec: H.264 + +? Vixen.S02.720p.CWS.WEBRip.AAC2.0.x264-BMF +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BMF + screen_size: 720p + season: 2 + source: Web + streaming_service: CWSeed + title: Vixen + type: episode + video_codec: H.264 + +? Abidin.Dino.DDY.WEBRip.AAC2.0.H.264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + source: Web + streaming_service: Digiturk Diledigin Yerde + title: Abidin Dino + type: movie + video_codec: H.264 + +? Fast.N.Loud.S08.1080p.DISC.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + screen_size: 1080p + season: 8 + source: Web + streaming_service: Discovery + title: Fast N Loud + type: episode + video_codec: H.264 + +? Bake.Off.Italia.S04.1080p.DPLY.WEBRip.AAC2.0.x264-Threshold +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: Threshold + screen_size: 1080p + season: 4 + source: Web + streaming_service: DPlay + title: Bake Off Italia + type: episode + video_codec: H.264 + +? Long.Riders.S01.DSKI.WEBRip.AAC2.0.x264-HorribleSubs +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: HorribleSubs + season: 1 + source: Web + streaming_service: Daisuki + title: Long Riders + type: episode + video_codec: H.264 + +? Milo.Murphys.Law.S01.720p.DSNY.WEB-DL.AAC2.0.x264-TVSmash +: audio_channels: '2.0' + audio_codec: AAC + release_group: TVSmash + screen_size: 720p + season: 1 + source: Web + streaming_service: Disney + title: Milo Murphys Law + type: episode + video_codec: H.264 + +? 30.for.30.S03E15.Doc.and.Darryl.720p.ESPN.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + episode: 15 + episode_title: Doc and Darryl + other: Rip + release_group: BTW + screen_size: 720p + season: 3 + source: Web + streaming_service: ESPN + title: 30 for 30 + type: episode + video_codec: H.264 + +? Boundless.S03.720p.ESQ.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + screen_size: 720p + season: 3 + source: Web + streaming_service: Esquire + title: Boundless + type: episode + video_codec: H.264 + +? Periodismo.Para.Todos.S2016E01.720p.ETTV.WEBRip.AAC2.0.H.264-braggart74 +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: braggart74 + screen_size: 720p + season: 2016 + source: Web + streaming_service: El Trece + title: Periodismo Para Todos + type: episode + video_codec: H.264 + year: 2016 + +? Just.Jillian.S01E01.1080p.ETV.WEBRip.AAC2.0.x264-GoApe +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: GoApe + screen_size: 1080p + season: 1 + source: Web + streaming_service: E! + title: Just Jillian + type: episode + video_codec: H.264 + +? New.Money.S01.1080p.ETV.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 1080p + season: 1 + source: Web + streaming_service: E! + title: New Money + type: episode + video_codec: H.264 + +? Gaming.Show.In.My.Parents.Garage.S02E01.The.Power.Up1000.FAM.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + episode_title: The Power Up1000 + other: Rip + release_group: RTN + season: 2 + source: Web + streaming_service: Family + title: Gaming Show In My Parents Garage + type: episode + video_codec: H.264 + +? Little.People.2016.S01E03.Proud.to.Be.You.and.Me.720p.FJR.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 3 + episode_title: Proud to Be You and Me + other: Rip + release_group: RTN + screen_size: 720p + season: 1 + source: Web + streaming_service: Family Jr + title: Little People + type: episode + video_codec: H.264 + year: 2016 + +? The.Pioneer.Woman.S00E08.Summer.Summer.Summer.720p.FOOD.WEB-DL.AAC2.0.x264-AJP69 +: audio_channels: '2.0' + audio_codec: AAC + episode: 8 + episode_title: Summer Summer Summer + release_group: AJP69 + screen_size: 720p + season: 0 + source: Web + streaming_service: Food Network + title: The Pioneer Woman + type: episode + video_codec: H.264 + +? Prata.da.Casa.S01E01.720p.FOX.WEBRip.AAC2.0.H.264-BARRY +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: BARRY + screen_size: 720p + season: 1 + source: Web + streaming_service: Fox + title: Prata da Casa + type: episode + video_codec: H.264 + +? Grandfathered.S01.720p.FOX.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + season: 1 + source: Web + streaming_service: Fox + title: Grandfathered + type: episode + video_codec: H.264 + +? Truth.and.Iliza.S01E01.FREE.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: BTN + season: 1 + source: Web + streaming_service: Freeform + title: Truth and Iliza + type: episode + video_codec: H.264 + +? Seven.Year.Switch.S01.720p.FYI.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + season: 1 + source: Web + streaming_service: FYI Network + title: Seven Year Switch + type: episode + video_codec: H.264 + +? NHL.2015.10.09.Leafs.vs.Red.Wings.Condensed.Game.720p.Away.Feed.GC.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + date: 2015-10-09 + episode_title: Leafs vs Red Wings Condensed Game + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: NHL GameCenter + title: NHL + type: episode + video_codec: H.264 + +? NHL.2016.01.26.Maple.Leafs.vs.Panthers.720p.Home.Feed.GC.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + date: 2016-01-26 + episode_title: Maple Leafs vs Panthers + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: NHL GameCenter + title: NHL + type: episode + video_codec: H.264 + +? Big.Brother.Canada.S05.GLBL.WEBRip.AAC2.0.H.264-RTN +: audio_channels: '2.0' + audio_codec: AAC + country: CA + other: Rip + release_group: RTN + season: 5 + source: Web + streaming_service: Global + title: Big Brother + type: episode + video_codec: H.264 + +? Pornolandia.S01.720p.GLOB.WEBRip.AAC2.0.x264-GeneX +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: GeneX + screen_size: 720p + season: 1 + source: Web + streaming_service: GloboSat Play + title: Pornolandia + type: episode + video_codec: H.264 + +? Transando.com.Laerte.S01.720p.GLOB.WEBRip.AAC2.0.x264-GeneX +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: GeneX + screen_size: 720p + season: 1 + source: Web + streaming_service: GloboSat Play + title: Transando com Laerte + type: episode + video_codec: H.264 + +? Flip.or.Flop.S01.720p.HGTV.WEBRip.AAC2.0.H.264-AJP69 +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: AJP69 + screen_size: 720p + season: 1 + source: Web + streaming_service: HGTV + title: Flip or Flop + type: episode + video_codec: H.264 + +? Kitten.Bowl.2014.720p.HLMK.WEBRip.AAC2.0.x264-monkee +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: monkee + screen_size: 720p + source: Web + streaming_service: Hallmark + title: Kitten Bowl + type: movie + video_codec: H.264 + year: 2014 + +? Still.Star-Crossed.S01E05.720p.HULU.WEB-DL.AAC2.0.H.264-VLAD +: audio_channels: '2.0' + audio_codec: AAC + episode: 5 + release_group: VLAD + screen_size: 720p + season: 1 + source: Web + streaming_service: Hulu + title: Still Star-Crossed + type: episode + video_codec: H.264 + +? EastEnders.2017.07.17.720p.iP.WEB-DL.AAC2.0.H.264-BTN +: audio_channels: '2.0' + audio_codec: AAC + date: 2017-07-17 + release_group: BTN + screen_size: 720p + source: Web + streaming_service: BBC iPlayer + title: EastEnders + type: episode + video_codec: H.264 + +? Handmade.in.Japan.S01E01.720p.iP.WEBRip.AAC2.0.H.264-SUP +: audio_channels: '2.0' + audio_codec: AAC + country: JP + episode: 1 + other: Rip + release_group: SUP + screen_size: 720p + season: 1 + source: Web + streaming_service: BBC iPlayer + title: Handmade in + type: episode + video_codec: H.264 + +? The.Chillenden.Murders.S01.720p.iP.WEBRip.AAC2.0.H.264-HAX +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: HAX + screen_size: 720p + season: 1 + source: Web + streaming_service: BBC iPlayer + title: The Chillenden Murders + type: episode + video_codec: H.264 + +? The.Street.S01.ITV.WEB-DL.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + release_group: RTN + season: 1 + source: Web + streaming_service: ITV + title: The Street + type: episode + video_codec: H.264 + +? Hope.for.Wildlife.S04.1080p.KNOW.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 1080p + season: 4 + source: Web + streaming_service: Knowledge Network + title: Hope for Wildlife + type: episode + video_codec: H.264 + +? Kim.of.Queens.S02.720p.LIFE.WEBRip.AAC2.0.H.264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + screen_size: 720p + season: 2 + source: Web + streaming_service: Lifetime + title: Kim of Queens + type: episode + video_codec: H.264 + +? The.Rachel.Maddow.Show.2017.02.22.720p.MNBC.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + date: 2017-02-22 + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: MSNBC + title: The Rachel Maddow Show + type: episode + video_codec: H.264 + +? Ignition.S06E12.720p.MTOD.WEB-DL.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 12 + release_group: RTN + screen_size: 720p + season: 6 + source: Web + streaming_service: Motor Trend OnDemand + title: Ignition + type: episode + video_codec: H.264 + +? Teen.Mom.UK.S01E01.Life.as.a.Teen.Mum.1080p.MTV.WEB-DL.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + country: GB + episode: 1 + episode_title: Life as a Teen Mum + release_group: BTW + screen_size: 1080p + season: 1 + source: Web + streaming_service: MTV + title: Teen Mom + type: episode + video_codec: H.264 + +? Undrafted.S01.720p.NFLN.WEBRip.AAC2.0.H.264-TTYL +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: TTYL + screen_size: 720p + season: 1 + source: Web + streaming_service: NFL Now + title: Undrafted + type: episode + video_codec: H.264 + +? NFL.2016.08.25.PreSeason.Cowboys.vs.Seahawks.720p.NFL.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + date: 2016-08-25 + episode_title: PreSeason Cowboys vs Seahawks + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: NFL + title: NFL + type: episode + video_codec: H.264 + +? Bunsen.is.a.Beast.S01E23.Guinea.Some.Lovin.1080p.NICK.WEBRip.AAC2.0.x264-TVSmash +: audio_channels: '2.0' + audio_codec: AAC + country: GN + episode: 23 + episode_title: Some Lovin + other: Rip + release_group: TVSmash + screen_size: 1080p + season: 1 + source: Web + streaming_service: Nickelodeon + title: Bunsen is a Beast + type: episode + video_codec: H.264 + +? Valkyrie.S01.720p.NRK.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + screen_size: 720p + season: 1 + source: Web + streaming_service: Norsk Rikskringkasting + title: Valkyrie + type: episode + video_codec: H.264 + +? Food.Forward.S01.720p.PBS.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + screen_size: 720p + season: 1 + source: Web + streaming_service: PBS + title: Food Forward + type: episode + video_codec: H.264 + +? SciGirls.S01E01.Turtle.Mania.720p.PBSK.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + episode_title: Turtle Mania + other: Rip + release_group: RTN + screen_size: 720p + season: 1 + source: Web + streaming_service: PBS Kids + title: SciGirls + type: episode + video_codec: H.264 + +? Powers.2015.S01.1080p.PSN.WEBRip.DD5.1.x264-NTb +: audio_channels: '5.1' + audio_codec: Dolby Digital + other: Rip + release_group: NTb + screen_size: 1080p + season: 1 + source: Web + streaming_service: Playstation Network + title: Powers + type: episode + video_codec: H.264 + year: 2015 + +? Escape.The.Night.S02E02.The.Masquerade.Part.II.1080p.RED.WEBRip.AAC5.1.VP9-BTW +: audio_channels: '5.1' + audio_codec: AAC + episode: 2 + episode_title: The Masquerade + other: Rip + part: 2 + release_group: VP9-BTW + screen_size: 1080p + season: 2 + source: Web + streaming_service: YouTube Red + title: Escape The Night + type: episode + +? Escape.The.Night.S02E02.The.Masquerade.Part.II.2160p.RED.WEBRip.AAC5.1.VP9-BTW +: audio_channels: '5.1' + audio_codec: AAC + episode: 2 + episode_title: The Masquerade + other: Rip + part: 2 + release_group: VP9-BTW + screen_size: 2160p + season: 2 + source: Web + streaming_service: YouTube Red + title: Escape The Night + type: episode + +? Escape.The.Night.S02E02.The.Masquerade.Part.II.720p.RED.WEBRip.AAC5.1.VP9-BTW +: audio_channels: '5.1' + audio_codec: AAC + episode: 2 + episode_title: The Masquerade + other: Rip + part: 2 + release_group: VP9-BTW + screen_size: 720p + season: 2 + source: Web + streaming_service: YouTube Red + title: Escape The Night + type: episode + +? The.Family.Law.S02E01.720p.SBS.WEB-DL.AAC2.0.H.264-BTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + release_group: BTN + screen_size: 720p + season: 2 + source: Web + streaming_service: SBS (AU) + title: The Family Law + type: episode + video_codec: H.264 + +? Theres.No.Joy.In.Beachville.The.True.Story.of.Baseballs.Origin.720p.SNET.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + source: Web + streaming_service: Sportsnet + title: Theres No Joy In Beachville The True Story of Baseballs Origin + type: movie + video_codec: H.264 + +? One.Night.Only.Alec.Baldwin.720p.SPIK.WEB-DL.AAC2.0.x264-NOGRP +: audio_channels: '2.0' + audio_codec: AAC + release_group: NOGRP + screen_size: 720p + source: Web + streaming_service: Spike + title: One Night Only Alec Baldwin + type: movie + video_codec: H.264 + +? Ink.Master.S08.720p.SPIK.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 720p + season: 8 + source: Web + streaming_service: Spike + title: Ink Master + type: episode + video_codec: H.264 + +? Jungle.Bunch.S01E01.Deep.Chasm.1080p.SPRT.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + episode_title: Deep Chasm + other: Rip + release_group: RTN + screen_size: 1080p + season: 1 + source: Web + streaming_service: Sprout + title: Jungle Bunch + type: episode + video_codec: H.264 + +? Ash.vs.Evil.Dead.S01.720p.STZ.WEBRip.AAC2.0.x264-NTb +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: NTb + screen_size: 720p + season: 1 + source: Web + streaming_service: Starz + title: Ash vs Evil Dead + type: episode + video_codec: H.264 + +? WWE.Swerved.S01.720p.WWEN.WEBRip.AAC2.0.H.264-PPKORE +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: PPKORE + screen_size: 720p + season: 1 + source: Web + streaming_service: WWE Network + title: WWE Swerved + type: episode + video_codec: H.264 + +? Face.Off.S11.1080p.SYFY.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + screen_size: 1080p + season: 11 + source: Web + streaming_service: Syfy + title: Face Off + type: episode + video_codec: H.264 + +? Conan.2016.09.22.Jeff.Garlin.720p.TBS.WEBRip.AAC2.0.H.264-NOGRP +: audio_channels: '2.0' + audio_codec: AAC + date: 2016-09-22 + episode_title: Jeff Garlin + other: Rip + release_group: NOGRP + screen_size: 720p + source: Web + streaming_service: TBS + title: Conan + type: episode + video_codec: H.264 + +? Swans.Crossing.S01.TUBI.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + season: 1 + source: Web + streaming_service: TubiTV + title: Swans Crossing + type: episode + video_codec: H.264 + +? The.Joy.of.Techs.S01.UKTV.WEB-DL.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + release_group: RTN + season: 1 + source: Web + streaming_service: UKTV + title: The Joy of Techs + type: episode + video_codec: H.264 + +? Rock.Icons.S01.720p.VH1.WEB-DL.AAC2.0.H.264-RTN +: audio_channels: '2.0' + audio_codec: AAC + release_group: RTN + screen_size: 720p + season: 1 + source: Web + streaming_service: VH1 + title: Rock Icons + type: episode + video_codec: H.264 + +? Desus.and.Mero.S01E130.2017.07.18.1080p.VICE.WEB-DL.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + date: 2017-07-18 + episode: 130 + release_group: RTN + screen_size: 1080p + season: 1 + source: Web + streaming_service: Viceland + title: Desus and Mero + type: episode + video_codec: H.264 + +? Graveyard.Carz.S07.1080p.VLCT.WEBRip.AAC2.0.x264-RTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: RTN + screen_size: 1080p + season: 7 + source: Web + streaming_service: Velocity + title: Graveyard Carz + type: episode + video_codec: H.264 + +? Other.Space.S01E01.1080p.YHOO.WEBRip.AAC2.0.x264-BTW +: audio_channels: '2.0' + audio_codec: AAC + episode: 1 + other: Rip + release_group: BTW + screen_size: 1080p + season: 1 + source: Web + streaming_service: Yahoo + title: Other Space + type: episode + video_codec: H.264 + +? Americas.Test.Kitchen.S17.720p.ATK.WEB-DL.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + release_group: BTN + screen_size: 720p + season: 17 + source: Web + streaming_service: America's Test Kitchen + title: Americas Test Kitchen + type: episode + video_codec: H.264 + +? Bushwhacked.Bugs.S01.AUBC.WEBRip.AAC2.0.H.264-DAWN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: DAWN + season: 1 + source: Web + streaming_service: ABC Australia + title: Bushwhacked Bugs + type: episode + video_codec: H.264 + +? VICE.S05E12.1080p.HBO.WEB-DL.AAC2.0.H.264-monkee +? VICE.S05E12.1080p.HBO-Go.WEB-DL.AAC2.0.H.264-monkee +? VICE.S05E12.1080p.HBOGo.WEB-DL.AAC2.0.H.264-monkee +: audio_channels: '2.0' + audio_codec: AAC + episode: 12 + release_group: monkee + screen_size: 1080p + season: 5 + source: Web + streaming_service: HBO Go + title: VICE + type: episode + video_codec: H.264 + +? Dix.Pour.Cent.S02.PLUZ.WEBRip.AAC2.0.H.264-TURTLE +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: TURTLE + season: 2 + source: Web + streaming_service: Pluzz + title: Dix Pour Cent + type: episode + video_codec: H.264 + +? Ulveson.och.Herngren.S01.720p.SVT.WEBRip.AAC2.0.H.264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + screen_size: 720p + season: 1 + source: Web + streaming_service: Sveriges Television + title: Ulveson och Herngren + type: episode + video_codec: H.264 + +? Bravest.Warriors.S03.1080p.VRV.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + screen_size: 1080p + season: 3 + source: Web + streaming_service: VRV + title: Bravest Warriors + type: episode + video_codec: H.264 + +? The.Late.Night.Big.Breakfast.S02.WME.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + season: 2 + source: Web + streaming_service: WatchMe + title: The Late Night Big Breakfast + type: episode + video_codec: H.264 + +? Hockey.Wives.S02.WNET.WEBRip.AAC2.0.H.264-BTW +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTW + season: 2 + source: Web + streaming_service: W Network + title: Hockey Wives + type: episode + video_codec: H.264 + +? Sin.City.Saints.S01.1080p.YHOO.WEBRip.AAC2.0.x264-NTb +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: NTb + screen_size: 1080p + season: 1 + source: Web + streaming_service: Yahoo + title: Sin City Saints + type: episode + video_codec: H.264 + +? 555.S01.1080p.VMEO.WEBRip.AAC2.0.x264-BTN +: audio_channels: '2.0' + audio_codec: AAC + other: Rip + release_group: BTN + screen_size: 1080p + season: 1 + source: Web + streaming_service: Vimeo + title: '555' + type: episode + video_codec: H.264 + +# All this below shouldn't match any streaming services +? London.2012.Olympics.CTV.Preview.Show.HDTV.x264-2HD +: alternative_title: Olympics CTV Preview Show + release_group: 2HD + source: HDTV + title: London + type: movie + video_codec: H.264 + year: 2012 + +? UFC.on.FOX.24.1080p.HDTV.x264-VERUM +: episode: 24 + release_group: VERUM + screen_size: 1080p + source: HDTV + title: UFC on FOX + type: episode + video_codec: H.264 + +? ESPN.E.60.2016.10.04.HDTV.x264-LoTV +: date: 2016-10-04 + episode: 60 + release_group: LoTV + source: HDTV + title: ESPN E + type: episode + video_codec: H.264 + +? GTTV.E3.All.Access.Live.Day.1.Xbox.Showcase.Preshow.HDTV.x264-SYS +: episode: 3 + episode_title: All Access Live Day 1 Xbox Showcase Preshow + release_group: SYS + source: HDTV + title: GTTV + type: episode + video_codec: H.264 diff --git a/libs/guessit/test/test_api.py b/libs/guessit/test/test_api.py index ca33df04..9abb84d9 100644 --- a/libs/guessit/test/test_api.py +++ b/libs/guessit/test/test_api.py @@ -27,6 +27,14 @@ def test_forced_binary(): assert ret and 'title' in ret and isinstance(ret['title'], six.binary_type) +@pytest.mark.skipif('sys.version_info < (3, 4)', reason="Path is not available") +def test_pathlike_object(): + from pathlib import Path + path = Path('Fear.and.Loathing.in.Las.Vegas.FRENCH.ENGLISH.720p.HDDVD.DTS.x264-ESiR.mkv') + ret = guessit(path) + assert ret and 'title' in ret + + def test_unicode_japanese(): ret = guessit('[阿维达].Avida.2006.FRENCH.DVDRiP.XViD-PROD.avi') assert ret and 'title' in ret diff --git a/libs/guessit/test/test_api_unicode_literals.py b/libs/guessit/test/test_api_unicode_literals.py index 3347a7d8..826f7cd1 100644 --- a/libs/guessit/test/test_api_unicode_literals.py +++ b/libs/guessit/test/test_api_unicode_literals.py @@ -53,6 +53,14 @@ if six.PY2: """ +def test_ensure_standard_string_class(): + class CustomStr(str): + pass + + ret = guessit(CustomStr('1080p'), options={'advanced': True}) + assert ret and 'screen_size' in ret and not isinstance(ret['screen_size'].input_string, CustomStr) + + def test_properties(): props = properties() assert 'video_codec' in props.keys() diff --git a/libs/guessit/test/test_options.py b/libs/guessit/test/test_options.py new file mode 100644 index 00000000..4f019b34 --- /dev/null +++ b/libs/guessit/test/test_options.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, pointless-string-statement +import os + +import pytest + +from ..options import get_options_file_locations, merge_options, load_config_file, ConfigurationException, \ + load_config + +__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) + + +def test_config_locations(): + homedir = '/root' + cwd = '/root/cwd' + + locations = get_options_file_locations(homedir, cwd, True) + assert len(locations) == 9 + + assert '/root/.guessit/options.json' in locations + assert '/root/.guessit/options.yml' in locations + assert '/root/.guessit/options.yaml' in locations + assert '/root/.config/guessit/options.json' in locations + assert '/root/.config/guessit/options.yml' in locations + assert '/root/.config/guessit/options.yaml' in locations + assert '/root/cwd/guessit.options.json' in locations + assert '/root/cwd/guessit.options.yml' in locations + assert '/root/cwd/guessit.options.yaml' in locations + + +def test_merge_configurations(): + c1 = {'param1': True, 'param2': True, 'param3': False} + c2 = {'param1': False, 'param2': True, 'param3': False} + c3 = {'param1': False, 'param2': True, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert not merged['param1'] + assert merged['param2'] + assert not merged['param3'] + + merged = merge_options(c3, c2, c1) + assert merged['param1'] + assert merged['param2'] + assert not merged['param3'] + + +def test_merge_configurations_lists(): + c1 = {'param1': [1], 'param2': True, 'param3': False} + c2 = {'param1': [2], 'param2': True, 'param3': False} + c3 = {'param1': [3], 'param2': True, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [1, 2, 3] + assert merged['param2'] + assert not merged['param3'] + + merged = merge_options(c3, c2, c1) + assert merged['param1'] == [3, 2, 1] + assert merged['param2'] + assert not merged['param3'] + + +def test_merge_configurations_deep(): + c1 = {'param1': [1], 'param2': {'d1': [1]}, 'param3': False} + c2 = {'param1': [2], 'param2': {'d1': [2]}, 'param3': False} + c3 = {'param1': [3], 'param2': {'d3': [3]}, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [1, 2, 3] + assert merged['param2']['d1'] == [1, 2] + assert merged['param2']['d3'] == [3] + assert 'd2' not in merged['param2'] + assert not merged['param3'] + + merged = merge_options(c3, c2, c1) + assert merged['param1'] == [3, 2, 1] + assert merged['param2'] + assert merged['param2']['d1'] == [2, 1] + assert 'd2' not in merged['param2'] + assert merged['param2']['d3'] == [3] + assert not merged['param3'] + + +def test_merge_configurations_pristine_all(): + c1 = {'param1': [1], 'param2': True, 'param3': False} + c2 = {'param1': [2], 'param2': True, 'param3': False, 'pristine': True} + c3 = {'param1': [3], 'param2': True, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [2, 3] + assert merged['param2'] + assert not merged['param3'] + + merged = merge_options(c3, c2, c1) + assert merged['param1'] == [2, 1] + assert merged['param2'] + assert not merged['param3'] + + +def test_merge_configurations_pristine_properties(): + c1 = {'param1': [1], 'param2': False, 'param3': True} + c2 = {'param1': [2], 'param2': True, 'param3': False, 'pristine': ['param2', 'param3']} + c3 = {'param1': [3], 'param2': True, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [1, 2, 3] + assert merged['param2'] + assert not merged['param3'] + + +def test_merge_configurations_pristine_properties_deep(): + c1 = {'param1': [1], 'param2': {'d1': False}, 'param3': True} + c2 = {'param1': [2], 'param2': {'d1': True}, 'param3': False, 'pristine': ['param2', 'param3']} + c3 = {'param1': [3], 'param2': {'d1': True}, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [1, 2, 3] + assert merged['param2'] + assert not merged['param3'] + + +def test_merge_configurations_pristine_properties2(): + c1 = {'param1': [1], 'param2': False, 'param3': True} + c2 = {'param1': [2], 'param2': True, 'param3': False, 'pristine': ['param1', 'param2', 'param3']} + c3 = {'param1': [3], 'param2': True, 'param3': False} + + merged = merge_options(c1, c2, c3) + assert merged['param1'] == [2, 3] + assert merged['param2'] + assert not merged['param3'] + + +def test_load_config_file(): + json_config = load_config_file(os.path.join(__location__, 'config', 'test.json')) + yml_config = load_config_file(os.path.join(__location__, 'config', 'test.yml')) + yaml_config = load_config_file(os.path.join(__location__, 'config', 'test.yaml')) + + assert json_config['expected_title'] == ['The 100', 'OSS 117'] + assert yml_config['expected_title'] == ['The 100', 'OSS 117'] + assert yaml_config['expected_title'] == ['The 100', 'OSS 117'] + + assert json_config['yaml'] is False + assert yml_config['yaml'] is True + assert yaml_config['yaml'] is True + + with pytest.raises(ConfigurationException) as excinfo: + load_config_file(os.path.join(__location__, 'config', 'dummy.txt')) + + assert excinfo.match('Configuration file extension is not supported for ".*?dummy.txt" file\\.') + + +def test_load_config(): + config = load_config({'no_default_config': True, 'param1': 'test', + 'config': [os.path.join(__location__, 'config', 'test.yml')]}) + + assert not config.get('param1') + + assert config.get('advanced_config') # advanced_config is still loaded from default + assert config['expected_title'] == ['The 100', 'OSS 117'] + assert config['yaml'] is True + + config = load_config({'no_default_config': True, 'param1': 'test'}) + + assert not config.get('param1') + + assert 'expected_title' not in config + assert 'yaml' not in config + + config = load_config({'no_default_config': True, 'param1': 'test', 'config': ['false']}) + + assert not config.get('param1') + + assert 'expected_title' not in config + assert 'yaml' not in config diff --git a/libs/guessit/test/test_yml.py b/libs/guessit/test/test_yml.py index c8e3d193..4f58a056 100644 --- a/libs/guessit/test/test_yml.py +++ b/libs/guessit/test/test_yml.py @@ -2,24 +2,20 @@ # -*- coding: utf-8 -*- # pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name import logging - +import os # io.open supports encoding= in python 2.7 from io import open # pylint: disable=redefined-builtin -import os -import yaml - -import six import babelfish import pytest - +import six +import yaml from rebulk.remodule import re from rebulk.utils import is_iterable -from guessit.options import parse_options -from ..yamlutils import OrderedDictYAMLLoader from .. import guessit - +from ..options import parse_options +from ..yamlutils import OrderedDictYAMLLoader logger = logging.getLogger(__name__) @@ -64,17 +60,17 @@ class EntryResult(object): def __repr__(self): if self.ok: return self.string + ': OK!' - elif self.warning: + if self.warning: return '%s%s: WARNING! (valid=%i, extra=%i)' % ('-' if self.negates else '', self.string, len(self.valid), len(self.extra)) - elif self.error: + if self.error: return '%s%s: ERROR! (valid=%i, missing=%i, different=%i, extra=%i, others=%i)' % \ ('-' if self.negates else '', self.string, len(self.valid), len(self.missing), len(self.different), len(self.extra), len(self.others)) - else: - return '%s%s: UNKOWN! (valid=%i, missing=%i, different=%i, extra=%i, others=%i)' % \ - ('-' if self.negates else '', self.string, len(self.valid), len(self.missing), len(self.different), - len(self.extra), len(self.others)) + + return '%s%s: UNKOWN! (valid=%i, missing=%i, different=%i, extra=%i, others=%i)' % \ + ('-' if self.negates else '', self.string, len(self.valid), len(self.missing), len(self.different), + len(self.extra), len(self.others)) @property def details(self): @@ -113,6 +109,8 @@ def files_and_ids(predicate=None): ids = [] for (dirpath, _, filenames) in os.walk(__location__): + if os.path.split(dirpath)[-1] == 'config': + continue if dirpath == __location__: dirpath_rel = '' else: @@ -134,7 +132,7 @@ class TestYml(object): Use $ marker to check inputs that should not match results. """ - options_re = re.compile(r'^([ \+-]+)(.*)') + options_re = re.compile(r'^([ +-]+)(.*)') files, ids = files_and_ids(filename_predicate) @@ -147,7 +145,7 @@ class TestYml(object): @pytest.mark.parametrize('filename', files, ids=ids) def test(self, filename, caplog): - caplog.setLevel(logging.INFO) + caplog.set_level(logging.INFO) with open(os.path.join(__location__, filename), 'r', encoding='utf-8') as infile: data = yaml.load(infile, OrderedDictYAMLLoader) entries = Results() @@ -173,8 +171,9 @@ class TestYml(object): entries.assert_ok() def check_data(self, filename, string, expected): - if six.PY2 and isinstance(string, six.text_type): - string = string.encode('utf-8') + if six.PY2: + if isinstance(string, six.text_type): + string = string.encode('utf-8') converts = [] for k, v in expected.items(): if isinstance(v, six.text_type): @@ -187,13 +186,13 @@ class TestYml(object): if not string_predicate or string_predicate(string): # pylint: disable=not-callable entry = self.check(string, expected) if entry.ok: - logger.debug('[' + filename + '] ' + str(entry)) + logger.debug('[%s] %s', filename, entry) elif entry.warning: - logger.warning('[' + filename + '] ' + str(entry)) + logger.warning('[%s] %s', filename, entry) elif entry.error: - logger.error('[' + filename + '] ' + str(entry)) + logger.error('[%s] %s', filename, entry) for line in entry.details: - logger.error('[' + filename + '] ' + ' ' * 4 + line) + logger.error('[%s] %s', filename, ' ' * 4 + line) return entry def check(self, string, expected): @@ -204,12 +203,10 @@ class TestYml(object): options = {} if not isinstance(options, dict): options = parse_options(options) - if 'implicit' not in options: - options['implicit'] = True try: result = guessit(string, options) except Exception as exc: - logger.error('[' + string + '] Exception: ' + str(exc)) + logger.error('[%s] Exception: %s', string, exc) raise exc entry = EntryResult(string, negates) @@ -255,10 +252,10 @@ class TestYml(object): return False if isinstance(next(iter(values)), babelfish.Language): # pylint: disable=no-member - expecteds = set([babelfish.Language.fromguessit(expected) for expected in expecteds]) + expecteds = {babelfish.Language.fromguessit(expected) for expected in expecteds} elif isinstance(next(iter(values)), babelfish.Country): # pylint: disable=no-member - expecteds = set([babelfish.Country.fromguessit(expected) for expected in expecteds]) + expecteds = {babelfish.Country.fromguessit(expected) for expected in expecteds} return values == expecteds def check_expected(self, result, expected, entry): @@ -271,10 +268,10 @@ class TestYml(object): if negates_key: entry.valid.append((expected_key, expected_value)) else: - entry.different.append((expected_key, expected_value, result[expected_key])) + entry.different.append((expected_key, expected_value, result[result_key])) else: if negates_key: - entry.different.append((expected_key, expected_value, result[expected_key])) + entry.different.append((expected_key, expected_value, result[result_key])) else: entry.valid.append((expected_key, expected_value)) elif not negates_key: diff --git a/libs/guessit/test/various.yml b/libs/guessit/test/various.yml index 72e2f602..5e689e0b 100644 --- a/libs/guessit/test/various.yml +++ b/libs/guessit/test/various.yml @@ -3,9 +3,9 @@ title: Fear and Loathing in Las Vegas year: 1998 screen_size: 720p - format: HD-DVD + source: HD-DVD audio_codec: DTS - video_codec: h264 + video_codec: H.264 release_group: ESiR ? Series/Duckman/Duckman - 101 (01) - 20021107 - I, Duckman.avi @@ -36,8 +36,9 @@ episode_format: Minisode episode: 1 episode_title: Good Cop Bad Cop - format: WEBRip - video_codec: XviD + source: Web + other: Rip + video_codec: Xvid ? Series/Kaamelott/Kaamelott - Livre V - Ep 23 - Le Forfait.avi : type: episode @@ -50,10 +51,10 @@ title: The Doors year: 1991 date: 2008-03-09 - format: BluRay + source: Blu-ray screen_size: 720p - audio_codec: AC3 - video_codec: h264 + audio_codec: Dolby Digital + video_codec: H.264 release_group: HiS@SiLUHD language: english website: sharethefiles.com @@ -63,14 +64,15 @@ title: MASH year: 1970 video_codec: DivX - format: DVD + source: DVD + other: [Dual Audio, Rip] ? the.mentalist.501.hdtv-lol.mp4 : type: episode title: the mentalist season: 5 episode: 1 - format: HDTV + source: HDTV release_group: lol ? the.simpsons.2401.hdtv-lol.mp4 @@ -78,7 +80,7 @@ title: the simpsons season: 24 episode: 1 - format: HDTV + source: HDTV release_group: lol ? Homeland.S02E01.HDTV.x264-EVOLVE.mp4 @@ -86,8 +88,8 @@ title: Homeland season: 2 episode: 1 - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: EVOLVE ? /media/Band_of_Brothers-e01-Currahee.mkv @@ -115,7 +117,7 @@ title: new girl season: 1 episode: 17 - format: HDTV + source: HDTV release_group: lol ? The.Office.(US).1x03.Health.Care.HDTV.XviD-LOL.avi @@ -125,8 +127,8 @@ season: 1 episode: 3 episode_title: Health Care - format: HDTV - video_codec: XviD + source: HDTV + video_codec: Xvid release_group: LOL ? The_Insider-(1999)-x02-60_Minutes_Interview-1996.mp4 @@ -154,18 +156,18 @@ season: 56 episode: 6 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 ? White.House.Down.2013.1080p.BluRay.DTS-HD.MA.5.1.x264-PublicHD.mkv : type: movie title: White House Down year: 2013 screen_size: 1080p - format: BluRay - audio_codec: DTS - audio_profile: HDMA - video_codec: h264 + source: Blu-ray + audio_codec: DTS-HD + audio_profile: Master Audio + video_codec: H.264 release_group: PublicHD audio_channels: "5.1" @@ -174,10 +176,10 @@ title: White House Down year: 2013 screen_size: 1080p - format: BluRay - audio_codec: DTS - audio_profile: HDMA - video_codec: h264 + source: Blu-ray + audio_codec: DTS-HD + audio_profile: Master Audio + video_codec: H.264 release_group: PublicHD audio_channels: "5.1" @@ -188,10 +190,10 @@ season: 1 episode: 1 screen_size: 720p - format: WEB-DL + source: Web audio_channels: "5.1" - video_codec: h264 - audio_codec: DolbyDigital + video_codec: H.264 + audio_codec: Dolby Digital release_group: NTb ? Despicable.Me.2.2013.1080p.BluRay.x264-VeDeTT.nfo @@ -199,37 +201,39 @@ title: Despicable Me 2 year: 2013 screen_size: 1080p - format: BluRay - video_codec: h264 + source: Blu-ray + video_codec: H.264 release_group: VeDeTT ? Le Cinquieme Commando 1971 SUBFORCED FRENCH DVDRiP XViD AC3 Bandix.mkv : type: movie - audio_codec: AC3 - format: DVD + audio_codec: Dolby Digital + source: DVD + other: Rip release_group: Bandix subtitle_language: French title: Le Cinquieme Commando - video_codec: XviD + video_codec: Xvid year: 1971 ? Le Seigneur des Anneaux - La Communauté de l'Anneau - Version Longue - BDRip.mkv : type: movie - format: BluRay title: Le Seigneur des Anneaux + source: Blu-ray + other: Rip ? La petite bande (Michel Deville - 1983) VF PAL MP4 x264 AAC.mkv : type: movie audio_codec: AAC language: French title: La petite bande - video_codec: h264 + video_codec: H.264 year: 1983 other: PAL ? Retour de Flammes (Gregor Schnitzler 2003) FULL DVD.iso : type: movie - format: DVD + source: DVD title: Retour de Flammes type: movie year: 2003 @@ -250,16 +254,16 @@ : type: movie year: 2014 title: A Common Title - edition: Special Edition + edition: Special ? Downton.Abbey.2013.Christmas.Special.HDTV.x264-FoV.mp4 : type: episode year: 2013 title: Downton Abbey episode_title: Christmas Special - video_codec: h264 + video_codec: H.264 release_group: FoV - format: HDTV + source: HDTV episode_details: Special ? Doctor_Who_2013_Christmas_Special.The_Time_of_The_Doctor.HD @@ -280,10 +284,10 @@ ? Robot Chicken S06-Born Again Virgin Christmas Special HDTV x264.avi : type: episode title: Robot Chicken - format: HDTV + source: HDTV season: 6 episode_title: Born Again Virgin Christmas Special - video_codec: h264 + video_codec: H.264 episode_details: Special ? Wicked.Tuna.S03E00.Head.To.Tail.Special.HDTV.x264-YesTV @@ -293,14 +297,14 @@ release_group: YesTV season: 3 episode: 0 - video_codec: h264 - format: HDTV + video_codec: H.264 + source: HDTV episode_details: Special ? The.Voice.UK.S03E12.HDTV.x264-C4TV : episode: 12 - video_codec: h264 - format: HDTV + video_codec: H.264 + source: HDTV title: The Voice release_group: C4TV season: 3 @@ -317,21 +321,21 @@ ? FlexGet.S01E02.TheName.HDTV.xvid : episode: 2 - format: HDTV + source: HDTV season: 1 title: FlexGet episode_title: TheName type: episode - video_codec: XviD + video_codec: Xvid ? FlexGet.S01E02.TheName.HDTV.xvid : episode: 2 - format: HDTV + source: HDTV season: 1 title: FlexGet episode_title: TheName type: episode - video_codec: XviD + video_codec: Xvid ? some.series.S03E14.Title.Here.720p : episode: 14 @@ -362,7 +366,7 @@ ? Something.Season.2.1of4.Ep.Title.HDTV.torrent : episode_count: 4 episode: 1 - format: HDTV + source: HDTV season: 2 title: Something episode_title: Title @@ -372,7 +376,7 @@ ? Show-A (US) - Episode Title S02E09 hdtv : country: US episode: 9 - format: HDTV + source: HDTV season: 2 title: Show-A type: episode @@ -402,23 +406,25 @@ type: movie ? Movies/El Bosque Animado (1987)/El.Bosque.Animado.[Jose.Luis.Cuerda.1987].[Xvid-Dvdrip-720 * 432].avi -: format: DVD +: source: DVD + other: Rip screen_size: 720x432 title: El Bosque Animado - video_codec: XviD + video_codec: Xvid year: 1987 type: movie ? Movies/El Bosque Animado (1987)/El.Bosque.Animado.[Jose.Luis.Cuerda.1987].[Xvid-Dvdrip-720x432].avi -: format: DVD +: source: DVD + other: Rip screen_size: 720x432 title: El Bosque Animado - video_codec: XviD + video_codec: Xvid year: 1987 type: movie ? 2009.shoot.fruit.chan.multi.dvd9.pal -: format: DVD +: source: DVD language: mul other: PAL title: shoot fruit chan @@ -426,7 +432,7 @@ year: 2009 ? 2009.shoot.fruit.chan.multi.dvd5.pal -: format: DVD +: source: DVD language: mul other: PAL title: shoot fruit chan @@ -435,25 +441,25 @@ ? The.Flash.2014.S01E01.PREAIR.WEBRip.XviD-EVO.avi : episode: 1 - format: WEBRip - other: Preair + source: Web + other: [Preair, Rip] release_group: EVO season: 1 title: The Flash type: episode - video_codec: XviD + video_codec: Xvid year: 2014 ? Ice.Lake.Rebels.S01E06.Ice.Lake.Games.720p.HDTV.x264-DHD : episode: 6 - format: HDTV + source: HDTV release_group: DHD screen_size: 720p season: 1 title: Ice Lake Rebels episode_title: Ice Lake Games type: episode - video_codec: h264 + video_codec: H.264 ? The League - S06E10 - Epi Sexy.mkv : episode: 10 @@ -463,23 +469,23 @@ type: episode ? Stay (2005) [1080p]/Stay.2005.1080p.BluRay.x264.YIFY.mp4 -: format: BluRay +: source: Blu-ray release_group: YIFY screen_size: 1080p title: Stay type: movie - video_codec: h264 + video_codec: H.264 year: 2005 ? /media/live/A/Anger.Management.S02E82.720p.HDTV.X264-DIMENSION.mkv -: format: HDTV +: source: HDTV release_group: DIMENSION screen_size: 720p title: Anger Management type: episode season: 2 episode: 82 - video_codec: h264 + video_codec: H.264 ? "[Figmentos] Monster 34 - At the End of Darkness [781219F1].mkv" : type: episode @@ -492,7 +498,7 @@ ? Game.of.Thrones.S05E07.720p.HDTV-KILLERS.mkv : type: episode episode: 7 - format: HDTV + source: HDTV release_group: KILLERS screen_size: 720p season: 5 @@ -501,7 +507,7 @@ ? Game.of.Thrones.S05E07.HDTV.720p-KILLERS.mkv : type: episode episode: 7 - format: HDTV + source: HDTV release_group: KILLERS screen_size: 720p season: 5 @@ -519,8 +525,8 @@ title: Star Trek Into Darkness year: 2013 screen_size: 720p - format: WEB-DL - video_codec: h264 + source: Web + video_codec: H.264 release_group: publichd ? /var/medias/series/The Originals/Season 02/The.Originals.S02E15.720p.HDTV.X264-DIMENSION.mkv @@ -529,8 +535,8 @@ season: 2 episode: 15 screen_size: 720p - format: HDTV - video_codec: h264 + source: HDTV + video_codec: H.264 release_group: DIMENSION ? Test.S01E01E07-FooBar-Group.avi @@ -539,202 +545,211 @@ - 1 - 7 episode_title: FooBar-Group # Make sure it doesn't conflict with uuid - mimetype: video/x-msvideo season: 1 title: Test type: episode ? TEST.S01E02.2160p.NF.WEBRip.x264.DD5.1-ABC : audio_channels: '5.1' - audio_codec: DolbyDigital + audio_codec: Dolby Digital episode: 2 - format: WEBRip - other: Netflix + source: Web + other: Rip release_group: ABC - screen_size: 4K + screen_size: 2160p season: 1 + streaming_service: Netflix title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.2015.12.30.720p.WEBRip.h264-ABC : date: 2015-12-30 - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 720p title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S01E10.24.1080p.NF.WEBRip.AAC2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 10 episode_title: '24' - format: WEBRip - other: Netflix + source: Web + other: Rip release_group: ABC screen_size: 1080p season: 1 + streaming_service: Netflix title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S01E10.24.1080p.NF.WEBRip.AAC2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 10 episode_title: '24' - format: WEBRip - other: Netflix + source: Web + other: Rip release_group: ABC screen_size: 1080p season: 1 + streaming_service: Netflix title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S01E10.24.1080p.NF.WEBRip.AAC.2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 10 episode_title: '24' - format: WEBRip - other: Netflix + source: Web + other: Rip release_group: ABC screen_size: 1080p season: 1 + streaming_service: Netflix title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S05E02.720p.iP.WEBRip.AAC2.0.H264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 2 - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 720p season: 5 title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S03E07.720p.WEBRip.AAC2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 7 - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 720p season: 3 title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S15E15.24.1080p.FREE.WEBRip.AAC2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 15 episode_title: '24' - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 1080p season: 15 title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.S11E11.24.720p.ETV.WEBRip.AAC2.0.x264-ABC : audio_channels: '2.0' audio_codec: AAC episode: 11 episode_title: '24' - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 720p season: 11 title: TEST type: episode - video_codec: h264 + video_codec: H.264 ? TEST.2015.1080p.HC.WEBRip.x264.AAC2.0-ABC : audio_channels: '2.0' audio_codec: AAC - format: WEBRip + source: Web + other: Rip release_group: ABC screen_size: 1080p title: TEST type: movie - video_codec: h264 + video_codec: H.264 year: 2015 ? TEST.2015.1080p.3D.BluRay.Half-SBS.x264.DTS-HD.MA.7.1-ABC : audio_channels: '7.1' - audio_codec: DTS - audio_profile: HDMA - format: BluRay + audio_codec: DTS-HD + audio_profile: Master Audio + source: Blu-ray other: 3D release_group: ABC screen_size: 1080p title: TEST type: movie - video_codec: h264 + video_codec: H.264 year: 2015 ? TEST.2015.1080p.3D.BluRay.Half-OU.x264.DTS-HD.MA.7.1-ABC : audio_channels: '7.1' - audio_codec: DTS - audio_profile: HDMA - format: BluRay + audio_codec: DTS-HD + audio_profile: Master Audio + source: Blu-ray other: 3D release_group: ABC screen_size: 1080p title: TEST type: movie - video_codec: h264 + video_codec: H.264 year: 2015 ? TEST.2015.1080p.3D.BluRay.Half-OU.x264.DTS-HD.MA.TrueHD.7.1.Atmos-ABC : audio_channels: '7.1' audio_codec: - - DTS - - TrueHD - - DolbyAtmos - audio_profile: HDMA - format: BluRay + - DTS-HD + - Dolby TrueHD + - Dolby Atmos + audio_profile: Master Audio + source: Blu-ray other: 3D release_group: ABC screen_size: 1080p title: TEST type: movie - video_codec: h264 + video_codec: H.264 year: 2015 ? TEST.2015.1080p.3D.BluRay.Half-SBS.x264.DTS-HD.MA.TrueHD.7.1.Atmos-ABC : audio_channels: '7.1' audio_codec: - - DTS - - TrueHD - - DolbyAtmos - audio_profile: HDMA - format: BluRay + - DTS-HD + - Dolby TrueHD + - Dolby Atmos + audio_profile: Master Audio + source: Blu-ray other: 3D release_group: ABC screen_size: 1080p title: TEST type: movie - video_codec: h264 + video_codec: H.264 year: 2015 ? TEST.2015.1080p.BluRay.REMUX.AVC.DTS-HD.MA.TrueHD.7.1.Atmos-ABC : audio_channels: '7.1' audio_codec: - - DTS - - TrueHD - - DolbyAtmos - audio_profile: HDMA - format: BluRay + - DTS-HD + - Dolby TrueHD + - Dolby Atmos + audio_profile: Master Audio + source: Blu-ray other: Remux release_group: ABC screen_size: 1080p @@ -743,58 +758,191 @@ year: 2015 ? Gangs of New York 2002 REMASTERED 1080p BluRay x264-AVCHD -: format: BluRay - other: Remastered +: source: Blu-ray + edition: Remastered screen_size: 1080p title: Gangs of New York type: movie - video_codec: h264 + video_codec: H.264 + video_profile: Advanced Video Codec High Definition year: 2002 ? Peep.Show.S06E02.DVDrip.x264-faks86.mkv : container: mkv episode: 2 - format: DVD + source: DVD + other: Rip release_group: faks86 season: 6 title: Peep Show type: episode - video_codec: h264 + video_codec: H.264 +# Episode title is indeed 'October 8, 2014' +# https://thetvdb.com/?tab=episode&seriesid=82483&seasonid=569935&id=4997362&lid=7 ? The Soup - 11x41 - October 8, 2014.mp4 : container: mp4 episode: 41 - episode_title: October 8 + episode_title: October 8, 2014 season: 11 title: The Soup type: episode - year: 2014 ? Red.Rock.S02E59.WEB-DLx264-JIVE : episode: 59 season: 2 - format: WEB-DL + source: Web release_group: JIVE title: Red Rock type: episode - video_codec: h264 + video_codec: H.264 ? Pawn.Stars.S12E31.Deals.On.Wheels.PDTVx264-JIVE : episode: 31 episode_title: Deals On Wheels season: 12 - format: DVB + source: Digital TV release_group: JIVE title: Pawn Stars type: episode - video_codec: h264 + video_codec: H.264 ? Duck.Dynasty.S09E09.Van.He-llsing.HDTVx264-JIVE : episode: 9 episode_title: Van He-llsing season: 9 - format: HDTV + source: HDTV release_group: JIVE title: Duck Dynasty type: episode - video_codec: h264 \ No newline at end of file + video_codec: H.264 + +? ATKExotics.16.01.24.Ava.Alba.Watersports.XXX.1080p.MP4-KTR +: title: ATKExotics + episode_title: Ava Alba Watersports + other: XXX + screen_size: 1080p + container: mp4 + release_group: KTR + type: episode + +? PutaLocura.15.12.22.Spanish.Luzzy.XXX.720p.MP4-oRo +: title: PutaLocura + episode_title: Spanish Luzzy + other: XXX + screen_size: 720p + container: mp4 + release_group: oRo + type: episode + +? French Maid Services - Lola At Your Service WEB-DL SPLIT SCENES MP4-RARBG +: title: French Maid Services + alternative_title: Lola At Your Service + source: Web + container: mp4 + release_group: RARBG + type: movie + +? French Maid Services - Lola At Your Service - Marc Dorcel WEB-DL SPLIT SCENES MP4-RARBG +: title: French Maid Services + alternative_title: [Lola At Your Service, Marc Dorcel] + source: Web + container: mp4 + release_group: RARBG + type: movie + +? PlayboyPlus.com_16.01.23.Eleni.Corfiate.Playboy.Romania.XXX.iMAGESET-OHRLY +: episode_title: Eleni Corfiate Playboy Romania + other: XXX + type: episode + +? TeenPornoPass - Anna - Beautiful Ass Deep Penetrated 720p mp4 +: title: TeenPornoPass + alternative_title: + - Anna + - Beautiful Ass Deep Penetrated + screen_size: 720p + container: mp4 + type: movie + +? SexInJeans.Gina.Gerson.Super.Nasty.Asshole.Pounding.With.Gina.In.Jeans.A.Devil.In.Denim.The.Finest.Ass.Fuck.Frolicking.mp4 +: title: SexInJeans Gina Gerson Super Nasty Asshole Pounding With Gina In Jeans A Devil In Denim The Finest Ass Fuck Frolicking + container: mp4 + type: movie + +? TNA Impact Wrestling HDTV 2017-06-22 720p H264 AVCHD-SC-SDH +: title: TNA Impact Wrestling + source: HDTV + date: 2017-06-22 + screen_size: 720p + video_codec: H.264 + video_profile: + - Advanced Video Codec High Definition + - Scalable Video Coding + release_group: SDH + type: episode + +? Katy Perry - Pepsi & Billboard Summer Beats Concert Series 2012 1080i HDTV 20 Mbps DD2.0 MPEG2-TrollHD.ts +: title: Katy Perry + alternative_title: Pepsi & Billboard Summer Beats Concert Series + year: 2012 + screen_size: 1080i + source: HDTV + video_bit_rate: 20Mbps + audio_codec: Dolby Digital + audio_channels: '2.0' + video_codec: MPEG-2 + release_group: TrollHD + container: ts + +? Justin Timberlake - MTV Video Music Awards 2013 1080i 32 Mbps DTS-HD 5.1.ts +: title: Justin Timberlake + alternative_title: MTV Video Music Awards + year: 2013 + screen_size: 1080i + video_bit_rate: 32Mbps + audio_codec: DTS-HD + audio_channels: '5.1' + container: ts + type: movie + +? Chuck Berry The Very Best Of Chuck Berry(2010)[320 Kbps] +: title: Chuck Berry The Very Best Of Chuck Berry + year: 2010 + audio_bit_rate: 320Kbps + type: movie + +? Title Name [480p][1.5Mbps][.mp4] +: title: Title Name + screen_size: 480p + video_bit_rate: 1.5Mbps + container: mp4 + type: movie + +? This.is.Us +: options: --no-default-config + title: This is Us + type: movie + +? This.is.Us +: options: --excludes country + title: This is Us + type: movie + +? MotoGP.2016x03.USA.Race.BTSportHD.1080p25 +: title: MotoGP + season: 2016 + year: 2016 + episode: 3 + screen_size: 1080p + frame_rate: 25fps + type: episode + +? BBC.Earth.South.Pacific.2010.D2.1080p.24p.BD25.DTS-HD +: title: BBC Earth South Pacific + year: 2010 + screen_size: 1080p + frame_rate: 24fps + source: Blu-ray + audio_codec: DTS-HD + type: movie diff --git a/libs/guessit/yamlutils.py b/libs/guessit/yamlutils.py index 2824575d..01ac7778 100644 --- a/libs/guessit/yamlutils.py +++ b/libs/guessit/yamlutils.py @@ -3,6 +3,7 @@ """ Options """ + try: from collections import OrderedDict except ImportError: # pragma: no-cover @@ -11,6 +12,8 @@ import babelfish import yaml +from .rules.common.quantity import BitRate, FrameRate, Size + class OrderedDictYAMLLoader(yaml.Loader): """ @@ -61,11 +64,18 @@ class CustomDumper(yaml.SafeDumper): def default_representer(dumper, data): """Default representer""" return dumper.represent_str(str(data)) + + CustomDumper.add_representer(babelfish.Language, default_representer) CustomDumper.add_representer(babelfish.Country, default_representer) +CustomDumper.add_representer(BitRate, default_representer) +CustomDumper.add_representer(FrameRate, default_representer) +CustomDumper.add_representer(Size, default_representer) def ordered_dict_representer(dumper, data): """OrderedDict representer""" - return dumper.represent_dict(data) + return dumper.represent_mapping('tag:yaml.org,2002:map', data.items()) + + CustomDumper.add_representer(OrderedDict, ordered_dict_representer) diff --git a/libs/rebulk/__version__.py b/libs/rebulk/__version__.py index 6b0a83ec..1f96b77a 100644 --- a/libs/rebulk/__version__.py +++ b/libs/rebulk/__version__.py @@ -4,4 +4,4 @@ Version module """ # pragma: no cover -__version__ = '0.7.7.dev0' +__version__ = '1.0.0' diff --git a/libs/rebulk/chain.py b/libs/rebulk/chain.py index 7817e8c0..dfb6ea44 100644 --- a/libs/rebulk/chain.py +++ b/libs/rebulk/chain.py @@ -24,7 +24,7 @@ class Chain(Pattern): Definition of a pattern chain to search for. """ - def __init__(self, rebulk, **kwargs): + def __init__(self, rebulk, chain_breaker=None, **kwargs): call(super(Chain, self).__init__, **kwargs) self._kwargs = kwargs self._match_kwargs = filter_match_kwargs(kwargs) @@ -32,6 +32,10 @@ class Chain(Pattern): self._regex_defaults = {} self._string_defaults = {} self._functional_defaults = {} + if callable(chain_breaker): + self.chain_breaker = chain_breaker + else: + self.chain_breaker = None self.rebulk = rebulk self.parts = [] @@ -161,10 +165,12 @@ class Chain(Pattern): return self.rebulk def _match(self, pattern, input_string, context=None): + # pylint: disable=too-many-locals,too-many-nested-blocks chain_matches = [] chain_input_string = input_string offset = 0 while offset < len(input_string): + chain_found = False current_chain_matches = [] valid_chain = True is_chain_start = True @@ -173,21 +179,39 @@ class Chain(Pattern): chain_part_matches, raw_chain_part_matches = Chain._match_chain_part(is_chain_start, chain_part, chain_input_string, context) + + Chain._fix_matches_offset(chain_part_matches, input_string, offset) + Chain._fix_matches_offset(raw_chain_part_matches, input_string, offset) + if raw_chain_part_matches: - Chain._fix_matches_offset(raw_chain_part_matches, input_string, offset) - offset = raw_chain_part_matches[-1].raw_end - chain_input_string = input_string[offset:] - if not chain_part.is_hidden: - current_chain_matches.extend(chain_part_matches) + grouped_matches_dict = dict() + for match_index, match in itertools.groupby(chain_part_matches, + lambda m: m.match_index): + grouped_matches_dict[match_index] = list(match) + + grouped_raw_matches_dict = dict() + for match_index, raw_match in itertools.groupby(raw_chain_part_matches, + lambda m: m.match_index): + grouped_raw_matches_dict[match_index] = list(raw_match) + + for match_index, grouped_raw_matches in grouped_raw_matches_dict.items(): + chain_found = True + offset = grouped_raw_matches[-1].raw_end + chain_input_string = input_string[offset:] + if not chain_part.is_hidden: + grouped_matches = grouped_matches_dict.get(match_index, []) + if self._chain_breaker_eval(current_chain_matches + grouped_matches): + current_chain_matches.extend(grouped_matches) + except _InvalidChainException: valid_chain = False if current_chain_matches: offset = current_chain_matches[0].raw_end break is_chain_start = False - if not current_chain_matches: + if not chain_found: break - if valid_chain: + if current_chain_matches and valid_chain: match = self._build_chain_match(current_chain_matches, input_string) chain_matches.append(match) @@ -244,6 +268,9 @@ class Chain(Pattern): chain_match.parent = match return match + def _chain_breaker_eval(self, matches): + return not self.chain_breaker or not self.chain_breaker(Matches(matches)) + @staticmethod def _fix_matches_offset(chain_part_matches, input_string, offset): for chain_part_match in chain_part_matches: @@ -273,14 +300,14 @@ class Chain(Pattern): if not is_chain_start: separator = chain_input_string[0:chain_part_matches[0].initiator.raw_start] - if len(separator) > 0: + if separator: return [] j = 1 for i in range(0, len(chain_part_matches) - 1): separator = chain_input_string[chain_part_matches[i].initiator.raw_end: chain_part_matches[i + 1].initiator.raw_start] - if len(separator) > 0: + if separator: break j += 1 truncated = chain_part_matches[:j] diff --git a/libs/rebulk/loose.py b/libs/rebulk/loose.py index 72543b1e..427b69a0 100644 --- a/libs/rebulk/loose.py +++ b/libs/rebulk/loose.py @@ -3,8 +3,18 @@ """ Various utilities functions """ -import inspect + + import sys +import inspect + +try: + from inspect import getfullargspec as getargspec + _fullargspec_supported = True +except ImportError: + _fullargspec_supported = False + from inspect import getargspec + from .utils import is_iterable if sys.version_info < (3, 4, 0): # pragma: no cover @@ -63,7 +73,7 @@ def function_args(callable_, *args, **kwargs): :return: (args, kwargs) matching the function signature :rtype: tuple """ - argspec = inspect.getargspec(callable_) # pylint:disable=deprecated-method + argspec = getargspec(callable_) # pylint:disable=deprecated-method return argspec_args(argspec, False, *args, **kwargs) @@ -80,7 +90,7 @@ def constructor_args(class_, *args, **kwargs): :return: (args, kwargs) matching the function signature :rtype: tuple """ - argspec = inspect.getargspec(_constructor(class_)) # pylint:disable=deprecated-method + argspec = getargspec(_constructor(class_)) # pylint:disable=deprecated-method return argspec_args(argspec, True, *args, **kwargs) @@ -99,7 +109,7 @@ def argspec_args(argspec, constructor, *args, **kwargs): :return: (args, kwargs) matching the function signature :rtype: tuple """ - if argspec.keywords: + if argspec.varkw: call_kwarg = kwargs else: call_kwarg = dict((k, kwargs[k]) for k in kwargs if k in argspec.args) # Python 2.6 dict comprehension @@ -110,6 +120,34 @@ def argspec_args(argspec, constructor, *args, **kwargs): return call_args, call_kwarg +if not _fullargspec_supported: + def argspec_args_legacy(argspec, constructor, *args, **kwargs): + """ + Return (args, kwargs) matching the argspec object + + :param argspec: argspec to use + :type argspec: argspec + :param constructor: is it a constructor ? + :type constructor: bool + :param args: + :type args: + :param kwargs: + :type kwargs: + :return: (args, kwargs) matching the function signature + :rtype: tuple + """ + if argspec.keywords: + call_kwarg = kwargs + else: + call_kwarg = dict((k, kwargs[k]) for k in kwargs if k in argspec.args) # Python 2.6 dict comprehension + if argspec.varargs: + call_args = args + else: + call_args = args[:len(argspec.args) - (1 if constructor else 0)] + return call_args, call_kwarg + argspec_args = argspec_args_legacy + + def ensure_list(param): """ Retrieves a list from given parameter. diff --git a/libs/rebulk/match.py b/libs/rebulk/match.py index 909c9fd6..8bf41245 100644 --- a/libs/rebulk/match.py +++ b/libs/rebulk/match.py @@ -3,8 +3,14 @@ """ Classes and functions related to matches """ -from collections import defaultdict, MutableSequence import copy +import itertools +from collections import defaultdict +try: + from collections.abc import MutableSequence +except ImportError: + from collections import MutableSequence + try: from collections import OrderedDict # pylint:disable=ungrouped-imports except ImportError: # pragma: no cover @@ -20,6 +26,7 @@ class MatchesDict(OrderedDict): """ A custom dict with matches property. """ + def __init__(self): super(MatchesDict, self).__init__() self.matches = defaultdict(list) @@ -33,33 +40,86 @@ class _BaseMatches(MutableSequence): _base = list _base_add = _base.append _base_remove = _base.remove + _base_extend = _base.extend - def __init__(self, matches=None, input_string=None): + def __init__(self, matches=None, input_string=None): # pylint: disable=super-init-not-called self.input_string = input_string self._max_end = 0 self._delegate = [] - self._name_dict = defaultdict(_BaseMatches._base) - self._tag_dict = defaultdict(_BaseMatches._base) - self._start_dict = defaultdict(_BaseMatches._base) - self._end_dict = defaultdict(_BaseMatches._base) - self._index_dict = defaultdict(_BaseMatches._base) + self.__name_dict = None + self.__tag_dict = None + self.__start_dict = None + self.__end_dict = None + self.__index_dict = None if matches: self.extend(matches) + @property + def _name_dict(self): + if self.__name_dict is None: + self.__name_dict = defaultdict(_BaseMatches._base) + for name, values in itertools.groupby([m for m in self._delegate if m.name], lambda item: item.name): + _BaseMatches._base_extend(self.__name_dict[name], values) + + return self.__name_dict + + @property + def _start_dict(self): + if self.__start_dict is None: + self.__start_dict = defaultdict(_BaseMatches._base) + for start, values in itertools.groupby([m for m in self._delegate], lambda item: item.start): + _BaseMatches._base_extend(self.__start_dict[start], values) + + return self.__start_dict + + @property + def _end_dict(self): + if self.__end_dict is None: + self.__end_dict = defaultdict(_BaseMatches._base) + for start, values in itertools.groupby([m for m in self._delegate], lambda item: item.end): + _BaseMatches._base_extend(self.__end_dict[start], values) + + return self.__end_dict + + @property + def _tag_dict(self): + if self.__tag_dict is None: + self.__tag_dict = defaultdict(_BaseMatches._base) + for match in self._delegate: + for tag in match.tags: + _BaseMatches._base_add(self.__tag_dict[tag], match) + + return self.__tag_dict + + @property + def _index_dict(self): + if self.__index_dict is None: + self.__index_dict = defaultdict(_BaseMatches._base) + for match in self._delegate: + for index in range(*match.span): + _BaseMatches._base_add(self.__index_dict[index], match) + + return self.__index_dict + def _add_match(self, match): """ Add a match :param match: :type match: Match """ - if match.name: - _BaseMatches._base_add(self._name_dict[match.name], (match)) - for tag in match.tags: - _BaseMatches._base_add(self._tag_dict[tag], match) - _BaseMatches._base_add(self._start_dict[match.start], match) - _BaseMatches._base_add(self._end_dict[match.end], match) - for index in range(*match.span): - _BaseMatches._base_add(self._index_dict[index], match) + if self.__name_dict is not None: + if match.name: + _BaseMatches._base_add(self._name_dict[match.name], (match)) + if self.__tag_dict is not None: + for tag in match.tags: + _BaseMatches._base_add(self._tag_dict[tag], match) + if self.__start_dict is not None: + _BaseMatches._base_add(self._start_dict[match.start], match) + if self.__end_dict is not None: + _BaseMatches._base_add(self._end_dict[match.end], match) + if self.__index_dict is not None: + for index in range(*match.span): + _BaseMatches._base_add(self._index_dict[index], match) if match.end > self._max_end: self._max_end = match.end @@ -69,14 +129,19 @@ class _BaseMatches(MutableSequence): :param match: :type match: Match """ - if match.name: - _BaseMatches._base_remove(self._name_dict[match.name], match) - for tag in match.tags: - _BaseMatches._base_remove(self._tag_dict[tag], match) - _BaseMatches._base_remove(self._start_dict[match.start], match) - _BaseMatches._base_remove(self._end_dict[match.end], match) - for index in range(*match.span): - _BaseMatches._base_remove(self._index_dict[index], match) + if self.__name_dict is not None: + if match.name: + _BaseMatches._base_remove(self._name_dict[match.name], match) + if self.__tag_dict is not None: + for tag in match.tags: + _BaseMatches._base_remove(self._tag_dict[tag], match) + if self.__start_dict is not None: + _BaseMatches._base_remove(self._start_dict[match.start], match) + if self.__end_dict is not None: + _BaseMatches._base_remove(self._end_dict[match.end], match) + if self.__index_dict is not None: + for index in range(*match.span): + _BaseMatches._base_remove(self._index_dict[index], match) if match.end >= self._max_end and not self._end_dict[match.end]: self._max_end = max(self._end_dict.keys()) @@ -311,7 +376,8 @@ class _BaseMatches(MutableSequence): return rindex return self.max_end - def holes(self, start=0, end=None, formatter=None, ignore=None, seps=None, predicate=None, index=None): # pylint: disable=too-many-branches,too-many-locals + def holes(self, start=0, end=None, formatter=None, ignore=None, seps=None, predicate=None, + index=None): # pylint: disable=too-many-branches,too-many-locals """ Retrieves a set of Match objects that are not defined in given range. :param start: @@ -431,14 +497,17 @@ class _BaseMatches(MutableSequence): """ return self._tag_dict.keys() - def to_dict(self, details=False, implicit=False): + def to_dict(self, details=False, first_value=False, enforce_list=False): """ Converts matches to a dict object. :param details if True, values will be complete Match object, else it will be only string Match.value property :type details: bool - :param implicit if True, multiple values will be set as a list in the dict. Else, only the first value - will be kept. - :type implicit: bool + :param first_value if True, only the first value will be kept. Else, multiple values will be set as a list in + the dict. + :type first_value: bool + :param enforce_list: if True, value is wrapped in a list even when a single value is found. Else, list values + are available under `values_list` property of the returned dict object. + :type enforce_list: bool :return: :rtype: dict """ @@ -446,10 +515,10 @@ class _BaseMatches(MutableSequence): for match in sorted(self): value = match if details else match.value ret.matches[match.name].append(match) - if value not in ret.values_list[match.name]: + if not enforce_list and value not in ret.values_list[match.name]: ret.values_list[match.name].append(value) if match.name in ret.keys(): - if implicit: + if not first_value: if not isinstance(ret[match.name], list): if ret[match.name] == value: continue @@ -459,7 +528,10 @@ class _BaseMatches(MutableSequence): continue ret[match.name].append(value) else: - ret[match.name] = value + if enforce_list and not isinstance(value, list): + ret[match.name] = [value] + else: + ret[match.name] = value return ret if six.PY2: # pragma: no cover @@ -499,15 +571,16 @@ class _BaseMatches(MutableSequence): def __repr__(self): return self._delegate.__repr__() - def insert(self, index, match): - self._delegate.insert(index, match) - self._add_match(match) + def insert(self, index, value): + self._delegate.insert(index, value) + self._add_match(value) class Matches(_BaseMatches): """ A custom list[Match] contains matches list. """ + def __init__(self, matches=None, input_string=None): self.markers = Markers(input_string=input_string) super(Matches, self).__init__(matches=matches, input_string=input_string) @@ -521,6 +594,7 @@ class Markers(_BaseMatches): """ A custom list[Match] containing markers list. """ + def __init__(self, matches=None, input_string=None): super(Markers, self).__init__(matches=None, input_string=input_string) @@ -533,8 +607,10 @@ class Match(object): """ Object storing values related to a single match """ + def __init__(self, start, end, value=None, name=None, tags=None, marker=None, parent=None, private=None, - pattern=None, input_string=None, formatter=None, conflict_solver=None): + pattern=None, input_string=None, formatter=None, conflict_solver=None, **kwargs): + # pylint: disable=unused-argument self.start = start self.end = end self.name = name @@ -547,7 +623,7 @@ class Match(object): self.pattern = pattern self.private = private self.conflict_solver = conflict_solver - self.children = Matches([], input_string) + self._children = None self._raw_start = None self._raw_end = None self.defined_at = pattern.defined_at if pattern else defined_at() @@ -559,6 +635,19 @@ class Match(object): """ return self.start, self.end + @property + def children(self): + """ + Children matches. + """ + if self._children is None: + self._children = Matches(None, self.input_string) + return self._children + + @children.setter + def children(self, value): + self._children = value + @property def value(self): """ @@ -592,12 +681,11 @@ class Match(object): """ if not self.children: return set([self.name]) - else: - ret = set() - for child in self.children: - for name in child.names: - ret.add(name) - return ret + ret = set() + for child in self.children: + for name in child.names: + ret.add(name) + return ret @property def raw_start(self): @@ -689,14 +777,14 @@ class Match(object): # crop is included in self, split current ... right = copy.deepcopy(current) current.end = start - if len(current) <= 0: + if not current: ret.remove(current) right.start = end - if len(right) > 0: + if right: ret.append(right) - elif end <= current.end and end > current.start: + elif current.end >= end > current.start: current.start = end - elif start >= current.start and start < current.end: + elif current.start <= start < current.end: current.end = start return filter_index(ret, predicate, index) @@ -736,13 +824,13 @@ class Match(object): def __eq__(self, other): if isinstance(other, Match): return self.span == other.span and self.value == other.value and self.name == other.name and \ - self.parent == other.parent + self.parent == other.parent return NotImplemented def __ne__(self, other): if isinstance(other, Match): return self.span != other.span or self.value != other.value or self.name != other.name or \ - self.parent != other.parent + self.parent != other.parent return NotImplemented def __lt__(self, other): diff --git a/libs/rebulk/pattern.py b/libs/rebulk/pattern.py index 767767b4..57b274e8 100644 --- a/libs/rebulk/pattern.py +++ b/libs/rebulk/pattern.py @@ -25,7 +25,7 @@ class Pattern(object): def __init__(self, name=None, tags=None, formatter=None, value=None, validator=None, children=False, every=False, private_parent=False, private_children=False, private=False, private_names=None, ignore_names=None, marker=False, format_all=False, validate_all=False, disabled=lambda context: False, log_level=None, - properties=None): + properties=None, post_processor=None, **kwargs): """ :param name: Name of this pattern :type name: str @@ -66,8 +66,10 @@ class Pattern(object): :type disabled: bool|function :param log_lvl: Log level associated to this pattern :type log_lvl: int + :param post_process: Post processing function + :type post_processor: func """ - # pylint:disable=too-many-locals + # pylint:disable=too-many-locals,unused-argument self.name = name self.tags = ensure_list(tags) self.formatters, self._default_formatter = ensure_dict(formatter, lambda x: x) @@ -90,6 +92,10 @@ class Pattern(object): self._log_level = log_level self._properties = properties self.defined_at = debug.defined_at() + if not callable(post_processor): + self.post_processor = None + else: + self.post_processor = post_processor @property def log_level(self): @@ -130,7 +136,7 @@ class Pattern(object): :return: :rtype: """ - if len(match) < 0 or match.value == "": + if not match or match.value == "": return False pattern_value = get_first_defined(self.values, [match.name, '__parent__', None], @@ -158,7 +164,7 @@ class Pattern(object): :return: :rtype: """ - if len(child) < 0 or child.value == "": + if not child or child.value == "": return False pattern_value = get_first_defined(self.values, [child.name, '__children__', None], @@ -221,12 +227,25 @@ class Pattern(object): for child in match.children: child.match_index = match_index matches.append(child) + matches = self._matches_post_process(matches) self._matches_privatize(matches) self._matches_ignore(matches) if with_raw_matches: return matches, raw_matches return matches + def _matches_post_process(self, matches): + """ + Post process matches with user defined function + :param matches: + :type matches: + :return: + :rtype: + """ + if self.post_processor: + return self.post_processor(matches, self) + return matches + def _matches_privatize(self, matches): """ Mark matches included in private_names with private flag. @@ -316,7 +335,7 @@ class StringPattern(Pattern): """ def __init__(self, *patterns, **kwargs): - call(super(StringPattern, self).__init__, **kwargs) + super(StringPattern, self).__init__(**kwargs) self._patterns = patterns self._kwargs = kwargs self._match_kwargs = filter_match_kwargs(kwargs) @@ -330,9 +349,8 @@ class StringPattern(Pattern): return self._match_kwargs def _match(self, pattern, input_string, context=None): - for index in call(find_all, input_string, pattern, **self._kwargs): - yield call(Match, index, index + len(pattern), pattern=self, input_string=input_string, - **self._match_kwargs) + for index in find_all(input_string, pattern, **self._kwargs): + yield Match(index, index + len(pattern), pattern=self, input_string=input_string, **self._match_kwargs) class RePattern(Pattern): @@ -341,7 +359,7 @@ class RePattern(Pattern): """ def __init__(self, *patterns, **kwargs): - call(super(RePattern, self).__init__, **kwargs) + super(RePattern, self).__init__(**kwargs) self.repeated_captures = REGEX_AVAILABLE if 'repeated_captures' in kwargs: self.repeated_captures = kwargs.get('repeated_captures') @@ -384,21 +402,21 @@ class RePattern(Pattern): for match_object in pattern.finditer(input_string): start = match_object.start() end = match_object.end() - main_match = call(Match, start, end, pattern=self, input_string=input_string, **self._match_kwargs) + main_match = Match(start, end, pattern=self, input_string=input_string, **self._match_kwargs) if pattern.groups: for i in range(1, pattern.groups + 1): name = names.get(i, main_match.name) if self.repeated_captures: for start, end in match_object.spans(i): - child_match = call(Match, start, end, name=name, parent=main_match, pattern=self, - input_string=input_string, **self._children_match_kwargs) + child_match = Match(start, end, name=name, parent=main_match, pattern=self, + input_string=input_string, **self._children_match_kwargs) main_match.children.append(child_match) else: start, end = match_object.span(i) if start > -1 and end > -1: - child_match = call(Match, start, end, name=name, parent=main_match, pattern=self, - input_string=input_string, **self._children_match_kwargs) + child_match = Match(start, end, name=name, parent=main_match, pattern=self, + input_string=input_string, **self._children_match_kwargs) main_match.children.append(child_match) yield main_match @@ -410,7 +428,7 @@ class FunctionalPattern(Pattern): """ def __init__(self, *patterns, **kwargs): - call(super(FunctionalPattern, self).__init__, **kwargs) + super(FunctionalPattern, self).__init__(**kwargs) self._patterns = patterns self._kwargs = kwargs self._match_kwargs = filter_match_kwargs(kwargs) @@ -439,14 +457,14 @@ class FunctionalPattern(Pattern): if self._match_kwargs: options = self._match_kwargs.copy() options.update(args) - yield call(Match, pattern=self, input_string=input_string, **options) + yield Match(pattern=self, input_string=input_string, **options) else: kwargs = self._match_kwargs if isinstance(args[-1], dict): kwargs = dict(kwargs) kwargs.update(args[-1]) args = args[:-1] - yield call(Match, *args, pattern=self, input_string=input_string, **kwargs) + yield Match(*args, pattern=self, input_string=input_string, **kwargs) def filter_match_kwargs(kwargs, children=False): diff --git a/libs/rebulk/processors.py b/libs/rebulk/processors.py index 0121c658..6a4f0bab 100644 --- a/libs/rebulk/processors.py +++ b/libs/rebulk/processors.py @@ -30,7 +30,7 @@ def _default_conflict_solver(match, conflicting_match): """ if len(conflicting_match.initiator) < len(match.initiator): return conflicting_match - elif len(match.initiator) < len(conflicting_match.initiator): + if len(match.initiator) < len(conflicting_match.initiator): return match return None @@ -51,6 +51,7 @@ class ConflictSolver(Rule): return _default_conflict_solver def when(self, matches, context): + # pylint:disable=too-many-nested-blocks to_remove_matches = IdentitySet() public_matches = [match for match in matches if not match.private] diff --git a/libs/rebulk/rebulk.py b/libs/rebulk/rebulk.py index 9326482b..42fb6440 100644 --- a/libs/rebulk/rebulk.py +++ b/libs/rebulk/rebulk.py @@ -68,6 +68,7 @@ class Rebulk(object): self._regex_defaults = {} self._string_defaults = {} self._functional_defaults = {} + self._chain_defaults = {} self._rebulks = [] def pattern(self, *pattern): @@ -207,6 +208,17 @@ class Rebulk(object): set_defaults(self._defaults, kwargs) return FunctionalPattern(*pattern, **kwargs) + def chain_defaults(self, **kwargs): + """ + Define default keyword arguments for patterns chain. + :param kwargs: + :type kwargs: + :return: + :rtype: + """ + self._chain_defaults = kwargs + return self + def chain(self, **kwargs): """ Add patterns chain, using configuration of this rebulk @@ -233,6 +245,7 @@ class Rebulk(object): :return: :rtype: """ + set_defaults(self._chain_defaults, kwargs) set_defaults(self._defaults, kwargs) return Chain(self, **kwargs) diff --git a/libs/rebulk/rules.py b/libs/rebulk/rules.py index 19b563ab..2514904f 100644 --- a/libs/rebulk/rules.py +++ b/libs/rebulk/rules.py @@ -140,10 +140,9 @@ class RemoveMatch(Consequence): # pylint: disable=abstract-method matches.remove(match) ret.append(match) return ret - else: - if when_response in matches: - matches.remove(when_response) - return when_response + if when_response in matches: + matches.remove(when_response) + return when_response class AppendMatch(Consequence): # pylint: disable=abstract-method @@ -164,12 +163,11 @@ class AppendMatch(Consequence): # pylint: disable=abstract-method matches.append(match) ret.append(match) return ret - else: - if self.match_name: - when_response.name = self.match_name - if when_response not in matches: - matches.append(when_response) - return when_response + if self.match_name: + when_response.name = self.match_name + if when_response not in matches: + matches.append(when_response) + return when_response class RenameMatch(Consequence): # pylint: disable=abstract-method diff --git a/libs/rebulk/test/default_rules_module.py b/libs/rebulk/test/default_rules_module.py index 5eed8e0d..065e1615 100644 --- a/libs/rebulk/test/default_rules_module.py +++ b/libs/rebulk/test/default_rules_module.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, len-as-condition from ..match import Match from ..rules import Rule, RemoveMatch, AppendMatch, RenameMatch, AppendTags, RemoveTags diff --git a/libs/rebulk/test/rebulk_rules_module.py b/libs/rebulk/test/rebulk_rules_module.py index 0bd5ef33..177e4a93 100644 --- a/libs/rebulk/test/rebulk_rules_module.py +++ b/libs/rebulk/test/rebulk_rules_module.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, len-as-condition from rebulk.rules import Rule, RemoveMatch, CustomRule diff --git a/libs/rebulk/test/rules_module.py b/libs/rebulk/test/rules_module.py index 887b81da..8814a68c 100644 --- a/libs/rebulk/test/rules_module.py +++ b/libs/rebulk/test/rules_module.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, len-as-condition from ..match import Match from ..rules import Rule diff --git a/libs/rebulk/test/test_chain.py b/libs/rebulk/test/test_chain.py index 8238ad63..2715abc2 100644 --- a/libs/rebulk/test/test_chain.py +++ b/libs/rebulk/test/test_chain.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member +# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member, len-as-condition import re from functools import partial @@ -301,3 +301,111 @@ def test_matches_6(): matches = rebulk.matches("Some Series E01E02E03E04E05E06") # No validator on parent, so it should give 4 episodes. assert len(matches) == 4 + + +def test_matches_7(): + seps_surround = partial(chars_surround, ' .-/') + rebulk = Rebulk() + rebulk.regex_defaults(flags=re.IGNORECASE) + rebulk.defaults(children=True, private_parent=True) + + rebulk.chain(). \ + regex(r'S(?P\d+)', validate_all=True, validator={'__parent__': seps_surround}). \ + regex(r'[ -](?P\d+)', validator=seps_surround).repeater('*') + + matches = rebulk.matches("Some S01") + assert len(matches) == 1 + matches[0].value = 1 + + matches = rebulk.matches("Some S01-02") + assert len(matches) == 2 + matches[0].value = 1 + matches[1].value = 2 + + matches = rebulk.matches("programs4/Some S01-02") + assert len(matches) == 2 + matches[0].value = 1 + matches[1].value = 2 + + matches = rebulk.matches("programs4/SomeS01middle.S02-03.andS04here") + assert len(matches) == 2 + matches[0].value = 2 + matches[1].value = 3 + + matches = rebulk.matches("Some 02.and.S04-05.here") + assert len(matches) == 2 + matches[0].value = 4 + matches[1].value = 5 + + +def test_chain_breaker(): + def chain_breaker(matches): + seasons = matches.named('season') + if len(seasons) > 1: + if seasons[-1].value - seasons[-2].value > 10: + return True + return False + + seps_surround = partial(chars_surround, ' .-/') + rebulk = Rebulk() + rebulk.regex_defaults(flags=re.IGNORECASE) + rebulk.defaults(children=True, private_parent=True, formatter={'season': int}) + + rebulk.chain(chain_breaker=chain_breaker). \ + regex(r'S(?P\d+)', validate_all=True, validator={'__parent__': seps_surround}). \ + regex(r'[ -](?P\d+)', validator=seps_surround).repeater('*') + + matches = rebulk.matches("Some S01-02-03-50-51") + assert len(matches) == 3 + matches[0].value = 1 + matches[1].value = 2 + matches[2].value = 3 + + +def test_chain_breaker_defaults(): + def chain_breaker(matches): + seasons = matches.named('season') + if len(seasons) > 1: + if seasons[-1].value - seasons[-2].value > 10: + return True + return False + + seps_surround = partial(chars_surround, ' .-/') + rebulk = Rebulk() + rebulk.regex_defaults(flags=re.IGNORECASE) + rebulk.defaults(chain_breaker=chain_breaker, children=True, private_parent=True, formatter={'season': int}) + + rebulk.chain(). \ + regex(r'S(?P\d+)', validate_all=True, validator={'__parent__': seps_surround}). \ + regex(r'[ -](?P\d+)', validator=seps_surround).repeater('*') + + matches = rebulk.matches("Some S01-02-03-50-51") + assert len(matches) == 3 + matches[0].value = 1 + matches[1].value = 2 + matches[2].value = 3 + + +def test_chain_breaker_defaults2(): + def chain_breaker(matches): + seasons = matches.named('season') + if len(seasons) > 1: + if seasons[-1].value - seasons[-2].value > 10: + return True + return False + + seps_surround = partial(chars_surround, ' .-/') + rebulk = Rebulk() + rebulk.regex_defaults(flags=re.IGNORECASE) + rebulk.chain_defaults(chain_breaker=chain_breaker) + rebulk.defaults(children=True, private_parent=True, formatter={'season': int}) + + rebulk.chain(). \ + regex(r'S(?P\d+)', validate_all=True, validator={'__parent__': seps_surround}). \ + regex(r'[ -](?P\d+)', validator=seps_surround).repeater('*') + + matches = rebulk.matches("Some S01-02-03-50-51") + assert len(matches) == 3 + matches[0].value = 1 + matches[1].value = 2 + matches[2].value = 3 diff --git a/libs/rebulk/test/test_debug.py b/libs/rebulk/test/test_debug.py index a35f95fd..cd9e556d 100644 --- a/libs/rebulk/test/test_debug.py +++ b/libs/rebulk/test/test_debug.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, protected-access, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, protected-access, invalid-name, len-as-condition from ..pattern import StringPattern from ..rebulk import Rebulk diff --git a/libs/rebulk/test/test_introspector.py b/libs/rebulk/test/test_introspector.py index 24c0c500..d83360f0 100644 --- a/libs/rebulk/test/test_introspector.py +++ b/libs/rebulk/test/test_introspector.py @@ -3,7 +3,7 @@ """ Introspector tests """ -# pylint: disable=no-self-use,pointless-statement,missing-docstring,protected-access,invalid-name +# pylint: disable=no-self-use,pointless-statement,missing-docstring,protected-access,invalid-name,len-as-condition from ..rebulk import Rebulk from .. import introspector from .default_rules_module import RuleAppend2, RuleAppend3 diff --git a/libs/rebulk/test/test_loose.py b/libs/rebulk/test/test_loose.py index bc0c6bca..d54e6bcc 100644 --- a/libs/rebulk/test/test_loose.py +++ b/libs/rebulk/test/test_loose.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, len-as-condition from ..loose import call diff --git a/libs/rebulk/test/test_match.py b/libs/rebulk/test/test_match.py index efbc63d0..87273d54 100644 --- a/libs/rebulk/test/test_match.py +++ b/libs/rebulk/test/test_match.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, unneeded-not +# pylint: disable=no-self-use, pointless-statement, missing-docstring, unneeded-not, len-as-condition import pytest import six @@ -419,7 +419,7 @@ class TestMaches(object): matches.extend(RePattern("Three", name="3bis", tags=["Three", "re"]).matches(input_string)) matches.extend(RePattern(r"(\w+)", name="words").matches(input_string)) - kvalues = matches.to_dict() + kvalues = matches.to_dict(first_value=True) assert kvalues == {"1": "One", "2": "Two", "3": "Three", @@ -427,7 +427,10 @@ class TestMaches(object): "words": "One"} assert kvalues.values_list["words"] == ["One", "Two", "Three"] - kvalues = matches.to_dict(details=True, implicit=True) + kvalues = matches.to_dict(enforce_list=True) + assert kvalues["words"] == ["One", "Two", "Three"] + + kvalues = matches.to_dict(details=True) assert kvalues["1"].value == "One" assert len(kvalues["2"]) == 2 diff --git a/libs/rebulk/test/test_pattern.py b/libs/rebulk/test/test_pattern.py index fadca5f2..beee1704 100644 --- a/libs/rebulk/test/test_pattern.py +++ b/libs/rebulk/test/test_pattern.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, unbalanced-tuple-unpacking +# pylint: disable=no-self-use, pointless-statement, missing-docstring, unbalanced-tuple-unpacking, len-as-condition import re import pytest @@ -97,6 +97,18 @@ class TestStringPattern(object): assert matches[0].name == "test" assert matches[0].value == "AB" + def test_post_processor(self): + def post_processor(matches, pattern): + assert len(matches) == 1 + assert isinstance(pattern, StringPattern) + + return [] + + pattern = StringPattern("Abyssinian", name="test", value="AB", post_processor=post_processor) + matches = list(pattern.matches(self.input_string)) + + assert len(matches) == 0 + class TestRePattern(object): """ @@ -384,10 +396,10 @@ class TestRePattern(object): children = matches[0].children assert len(children) == 2 - assert children[0].name is "test" + assert children[0].name == "test" assert children[0].value == "HE" - assert children[1].name is "test" + assert children[1].name == "test" assert children[1].value == "HE" pattern = RePattern("H(?Pe.)(?Prew)", name="test", value="HE") @@ -795,8 +807,7 @@ class TestValidator(object): def invalid_func(match): if match.name == 'intParam': return True - else: - return match.value.startswith('abc') + return match.value.startswith('abc') pattern = RePattern(r"contains (?P\d+)", formatter=int, validator=invalid_func, validate_all=True, children=True) @@ -807,8 +818,7 @@ class TestValidator(object): def func(match): if match.name == 'intParam': return True - else: - return match.value.startswith('contains') + return match.value.startswith('contains') pattern = RePattern(r"contains (?P\d+)", formatter=int, validator=func, validate_all=True, children=True) diff --git a/libs/rebulk/test/test_processors.py b/libs/rebulk/test/test_processors.py index 7afd4535..69be20f0 100644 --- a/libs/rebulk/test/test_processors.py +++ b/libs/rebulk/test/test_processors.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member +# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member, len-as-condition from ..pattern import StringPattern, RePattern from ..processors import ConflictSolver diff --git a/libs/rebulk/test/test_rebulk.py b/libs/rebulk/test/test_rebulk.py index bf0bc966..bafa01d4 100644 --- a/libs/rebulk/test/test_rebulk.py +++ b/libs/rebulk/test/test_rebulk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member +# pylint: disable=no-self-use, pointless-statement, missing-docstring, no-member, len-as-condition from ..rebulk import Rebulk from ..rules import Rule diff --git a/libs/rebulk/test/test_rules.py b/libs/rebulk/test/test_rules.py index 47b6f5fc..cd7c0dd3 100644 --- a/libs/rebulk/test/test_rules.py +++ b/libs/rebulk/test/test_rules.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, no-member +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name, no-member, len-as-condition import pytest from rebulk.test.default_rules_module import RuleRemove0, RuleAppend0, RuleRename0, RuleAppend1, RuleRemove1, \ RuleRename1, RuleAppend2, RuleRename2, RuleAppend3, RuleRename3, RuleAppendTags0, RuleRemoveTags0, \ diff --git a/libs/rebulk/test/test_validators.py b/libs/rebulk/test/test_validators.py index 38511cbf..863ec093 100644 --- a/libs/rebulk/test/test_validators.py +++ b/libs/rebulk/test/test_validators.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name +# pylint: disable=no-self-use, pointless-statement, missing-docstring, invalid-name,len-as-condition from functools import partial diff --git a/libs/rebulk/utils.py b/libs/rebulk/utils.py index a49fe4ff..85ddd41e 100644 --- a/libs/rebulk/utils.py +++ b/libs/rebulk/utils.py @@ -3,12 +3,15 @@ """ Various utilities functions """ -from collections import MutableSet +try: + from collections.abc import MutableSet +except ImportError: + from collections import MutableSet from types import GeneratorType -def find_all(string, sub, start=None, end=None, ignore_case=False): +def find_all(string, sub, start=None, end=None, ignore_case=False, **kwargs): """ Return all indices in string s where substring sub is found, such that sub is contained in the slice s[start:end]. @@ -41,6 +44,7 @@ def find_all(string, sub, start=None, end=None, ignore_case=False): :return: all indices in the input string :rtype: __generator[str] """ + #pylint: disable=unused-argument if ignore_case: sub = sub.lower() string = string.lower() @@ -65,10 +69,8 @@ def get_first_defined(data, keys, default_value=None): :rtype: """ for key in keys: - try: + if key in data: return data[key] - except KeyError: - pass return default_value @@ -84,6 +86,7 @@ def is_iterable(obj): We don't need to check for the Python 2 `unicode` type, because it doesn't have an `__iter__` attribute anyway. """ + # pylint: disable=consider-using-ternary return hasattr(obj, '__iter__') and not isinstance(obj, str) or isinstance(obj, GeneratorType) @@ -118,7 +121,7 @@ class IdentitySet(MutableSet): # pragma: no cover """ Set based on identity """ - def __init__(self, items=None): + def __init__(self, items=None): # pylint: disable=super-init-not-called if items is None: items = [] self.refs = set(map(_Ref, items)) @@ -132,11 +135,11 @@ class IdentitySet(MutableSet): # pragma: no cover def __len__(self): return len(self.refs) - def add(self, elem): - self.refs.add(_Ref(elem)) + def add(self, value): + self.refs.add(_Ref(value)) - def discard(self, elem): - self.refs.discard(_Ref(elem)) + def discard(self, value): + self.refs.discard(_Ref(value)) def update(self, iterable): """

Jk^IwowFrX->o;|1cQ!3ke_;$i`X0>Ez9;y^@Fe7#JY20ae zpUqGmJ4V0h7e*(dA{8~PbTO4`;jm`>s?S`a+l=|;Mxt+R{lw`r^*!mk5%DV8%iFjO zjVB7he&pzepl!TKT%wwSilwn8LMZAcC4Z^V*N`r8Wzll<&-d4FFWu>B`^WU_RR;1O zGyC1={T7OZk^Y}7UsS~0q~OO3xrNwX(U0DEv?*=koKQR~e*8F&i-WKB(cOW8>Z^R+ z$3R30__{rSR0BB%B1#DQK&pWp6I$Mt&w`EIMB0)9H$ZI3z$*}2a_|bomIAy|*p{Y< zVzZ8~gW1GjHjru{$HbO*!xAkXUK76G(JLei^`78caYIveW z1CVJT7$RZHoIT{monO^Y5PsK%pLT+|KpcSt0*L~W0i*;-Gmzgv=78(~!LbV%QaxIL z^$|WwA}8Tty<({GCegz8s($p!_2*JatS?rcM_(!kfPxGt1c74UQb7n5R6qd-3aSvG zxc1A96hmbwceOG-R8Aqu;0=FmTWs+>L(%C<5Nv#jY@ciQZu1E|_>u z3_P&`MZ}B0&jsXb`VMa*S2*N*)gL9L0h6H6L<=d9#uXl2WqyOl{ru+by%p(1eBFn) zfmc&3u};SqYr(PCUsRu%ZOgWI!NR5PgYfPi$nlv2{o zDzUUkOA8|1-7AW83)1jPN(<7pNH@~mCEdMo$KSp8kC`+3o#&Z(X3jaw?w*+soB4G6 z1#{J1YPE+xG8^cn6H3B?V;%bR<$M7m?(Y&$5)HgXUVJ{ea7kPl%7*ndYTB|NMwR?% zT59|`N}a7GW!1^Sa3iH9b<$MgXSnm@L@{%}uRy7IMUO>%gMZgl<^oHzL=@?la59~Z z$*7Q-kUErxMeE4GV5#Yfq2jp5&{?2r599-vFZa=E* z+gjFsysFVWon4$*mn6+AD^Ip7guhFtlJO83LSZ-1RY_<)6Ffksj^&IFVzsQOjIL(Y zXpSUx4B?tvCf>YYHOQ5UEEjFWnk!vq-TDL{hi8Q*Cs*lV4mHtB+5u=b$s4$8`P?h$37+=pIoWwx$$B-6fafY;-31hNSp1Z4HCRng62Did_%!MUGY^;!(S`;E zHYf&?bhE81A5>#~W8XQvVGPsrpREj)vwsFye7U%kj~S*1kPfieTn;7tD0+v4=HD|D z|G!HMG6xuC_zkp_=*bcRURm*m-Oa^*|ATRq5DI?rj?k{)6Ikl4HHPpE1#6t#@G{m+ z&JR=U(8ARrei)wSGt9$>w^Z^3enLk6`{{8F^G)TE%mV(4{-&*i+^ZM23nkGd6{>+oywgs*<13!A9X`o(yw^%jy%_##B-h!qit3 z6VF(yzDjL$eT1W~9EFLhizU$FiRFk|DKXVej7DQa9jggO?Es_7gMx=o6{ysmNl+K&q}|8xBl;zr+AE}De3M)thly+?Ql ztmNVsuN)M6lX9Ytml@s$F9}XoG`qiz3l74Aha&OHs86NbV*GYu48ZJJ4WwzKqWEjv= zmJBy#t7z>U%^nLCGy6SAX!e~uucJ*<$E9cztMlpV-3qIHYr~2^bB4#+Sal@lm#!>d zq74iA`s+1fbY1ffji2<>X7nX4AhbDai+FScw*4yFIy?+JD?cZ-4xqZ;ARl zY9_L!pVRPnlen#(f|J>2-J#XHX+?IpV57dzk|#`?eDO(f*%GVHoR&_B6y~Y!dmW34 z&7Y-)rGb~Ssz-i9#h>*`j4bbO8M)^D)pRa0~l37AJDJ^f>tS2)SuBdA44k%H;c%bJ~ z_m>WbOOM{M&&84+y~ng2C^UNCJ{Nyhfu<@>$_f&7C*`#jr1WvTu@z+SalC^SWdCt| zfE9#*I6gX-FXbDqqFPYyvsne*pj@t51(Tp$UdAyhChI7nQ5q&dzHpOQn1wK-Mqt#; z%ygqLeP(9KY;N;ZW@e*N*eo-%KS@07p<+Q*oKE0y?ygd5UX&X?e|uGgj*{3^IhMc1 z?-{)sO$_1|Zr1*^?Y&>B{Q0B(MU-!9EBp|H0?(uy>U-DVqehs+k#ot;QwOny^PNvU zh{nOBSkq~7zDNMiIz6^Vm!c4EgwkR}J|A0F4GF7Ee(SYFT-o1T9+Hl5a7|(~8|zqh zG3QQka{$zXQCsM)MYLp3+`hVd*28W62jTv;JmAjDojKIr2(6V`z@kmT%;T}z>)q9T^y$y|wI>(nkovw4q`?Q>f-gK@RrzXiuM3&byq#2iA>LT<; zRZcw`8pQ%4(^%W*vAZHWA*&}prf5!QtA#ft$J>$*^|>_BWQkD^2y4bfqJ|wg>zeZO z->lodUvYjOWfLoH#4P>iN$H7_QOS^F{_6Emp|aMd3dGtWrj$y0yjZzyghTR2#CN?^)986+jr_IYPNBQ)L?4x0lY_G#56EgcngG z&}BPaYZ(Hnvl|+=w>m|}~QO3mDoFZ7iRA3a-~R$bWIQ_FARM~$N<5yw~I zDjEkd^Ge54F)0%Ctiu%IArPTT90aGAYt%(|;9aQcRCEf32Xw z#$1n;vi?x<;w7uUbUc(P?~b$*R!{;S?lZc_8cK}0RWB6^17kH!Pg@`$9Bn1T{uxX%(FYlt!BCP*t-e~Hz=XUe0HR^kmxu)uvb@mQk) zRLCe;#$f2QX7qLnYhp7h&585=4#0){ErL7 zl*h%WY!sBx0w6@h8q|^6!+qrJ?7Mq%D5nAzeI<-;x%gAAA}A2n#B*;)i3Kbys+I z@e{-IhP%k0UH1Z*$DXAh{;JVTz>6?9hFe(X z?3=P4?b6seA!$MmJ}spO5FjVRKQGtb9^OOn2Ryi1@QC<*crN+zbfH`ddw#YH7v1`d z7HK;1a5=_Ke$6lDwEH8pg&G^Z{sI4}U@q+=RNROPdF^L;L}7sC)RNTh@)41`dlxPC z_u*9p`qZ@PMj8j@6HA*am0i<7kW;=iO(s@*&B!rojnQa?DP4lm(eDz`wotG zhJRKucy%@ZYTaj3%R4ST4hiiOdBjQ!~ zg{)QCaq3eN?|Aoh-;n2B9tu6eCgjibWwNU1WwQ1R=N6^JgU`h@TX#H%d~oOU+DM&} zwl1Cggu6+X9oPn!4?L#=eM^iU9@N9By*3I-e;1b+e?HaTUm$IoT{mehuC;k!oMQ<+ zN;T#cF)1Ynl#YB{B<r(THaOq)g)gV4lcKg5JK%fZob90sX`w z1C$P$eFH*3-_!-llYw}14gg7oR|3ethytQxnn5&|hW9`= z3os>@^q{9pJ%AOt+y_{ZOPYV4|9~zyL7*qB1x4+qjK{o{eR8JqAwlpKD{D|xCiz#Eh#8XWsL z2$zUgO_({Jay2ZuV2)a;uq9it>Urt+??um)RLDw6@@{PCcXgslndFMYFA$ev4@b^y zw{@e?2rsDP0_kzE?~uN4hX17`&VFs#@8Lpa{8o@%whtHf1H4FK|J0LLzMTLFa-MO0 z34_tff%x`>VdU9ousGc^worZ^7>Gpps4ceYUqKmt7HR<_2+H-HH} z4e*~C*S0#WZW}JVeQNm~RsSI1^}-Th51`sWe_}geywO#YRt)SQ1bPudkTqe0Z1&&F zMaNgSJszJviktFO5I%&RC+uHdRB;E(2ol8 zfY;;c0JUNW1|O#1ys{;`52zmM-QZFg-v^YAc>pd6^N9j|Hs6BqG{8xJ%MDm2myO`n zdvJLkNdT)R9UvWYas9bS0r(KVq<`2GK-$Ru4d^!`Kl5HTs)LxU3_+t;{|3(rm{77~ z|3t+jT!0ob3*P0zJOB|Rs2X(ilNwmI6M^hKAZq+|1D8M$3ut+07cSe_55OuT z=U^&EN_jw~6quqjs|LdT%ztC&`Pcj$2=ffwCZzrxz^Nq(gyTgI!py{jvfUT~C<=-c zn09aj=^C_8`SV`DYgq!oNf{XfrYZm=#_RO*LlsUvi0SwK%2vl?5N2p8AlsOYwMftD zfN-X1pIH$#f~i^+0wY_w1??fhJ;H0I`g9i34SH2S1%7`Ij$F+Kz`F!*%`4{FHNbfZ zJJw#_>bMWa&Z`cr6!T`YCAR?|=8xl)%x!%P@HxdsrY9r8m7g3BF1&oHOS;)H65xaX z8DwXQpg|b8Mvl#vK3ff!5J1^19)Kv5(t)QP7(4H+f%vQk8(_^~9?=AZ>T-kVM0bJb zycVc*DmJ@)Jb!rlrMT##5EWWe@i>Jf!R)|F~n@#HU0k1hdfJFqLk%h5r zn*1?#AqjZRoJ(bes0QyGP5!jNz~3K#w*TMgH`d5=n$4I%(`;ZOnWI6g9RGqXI?((R zPzzu@Zxu-w@6v*J@AMm6~=8Q&?xE76KZ0OwJl1NfN>0T5`G`^hU`NARJcq8tE{rZ4e9 zW&(x}Hjk3-^BjQNpc??jG%fL1A+62s{r3-N4HvzZV+H++3)r~*!yZ}6$i64U|xwF!PGH;Th!V2Dd_YWAXUw8gZT^aDExe}zzX^_2KT8m z0#w$)sRCXjPn6ZG;0st#OAYR};goT}#rrx<0JY>(4awr)0K6&7KFLP`LkX7PtUhxD zH_F#BFaw%Wo}lE~|HW#H=N{DZ`5T6SA+2Tb*+BC<_)L~BZQ}8&@yH#Rc!T?>u!jO@ z*%8ZIDgaB7#ZSh74_qql0GCPt=qVfrlc2ec4Q%+pM--J0#UPHp>)#*))F#a@8lVCX zTrw(Tj)$_1quocYtQ1(l_u3xRCW2|uoY=~eY1F?5nz~^GFA3;CEk6LftrvktY$gJ; z_$M4B1sM%^`Zpw8=rI_2wJoz>r~Jlk3D@XD^moYW)66#3R=5YIwmii5+s#t?IYVVN zn(Z{4)91$d`zZ$x$FIjYSc?9BL72#aHN zq>+=Cr|NwEnm63j*8U0R!$ z&ovx-4%?V|&LSd^^i8+A??%5goFz+L&;p@edgKW^_bJkm5#YBWN5YD|Yf@Ky&NWLX zyS*c4vk*BG-nB(Km(5gQz0JISa}u`oIgar-r;N1VrI4>c3^#AE<-VsNPuAo1xoeie zg50zfSJ%9i+xMpJhs!ECzgG^Uf_H-{qiQHd!MaJuD@01ZAk~3`K!(>#{J#^g@%Mfi z+fGhr3p$>pl54g6n9N?aD(ze_(C;_#u_|>?6a0j*DXEsC{!n%OBb&UkqI%>)RSrMH zq}xh(rDQTd>s7nhGDXg39fRHJ(HCM5p8RRE@w0O)b5q@ow@ew?&<&Y9ZxhIwOR3hs zG#Y`$?4MR;9;!|Fu&`1!n?#S^sARoAqnp(q%U+8*x^S-w_-z!4KF7BqE(?H}f8U+` z_%x&XE_6dUyXA5sJH}Yhtc5d&VE*b0FZ*_xkQW-K>+!8Mm&dr_`ycj=7Gv>MrVDD+ z&Uam_RtRdSj=7I#L-~{=Jp{8u4-?sNi7nD zSfv zdbE-j^>(DhER}ahPCz0|5)$JFqen#!+<{r#h42T)&9wGJ-XYi5A%SS0;# z&@w7tH=y2sEV@9&+Yn$$4=nwGC3ZFta7~$6!ErhiR4{{zL{MSzPY447d+ghAK1Q?W zKraP)*FT2{pmPJE`Ckh;K!^ka9}osZy*jKCFprDX(J}+DYoZF856})L#)|Z~l<1SJ zxk{6iV|I+_^V`Ifn3JriN)x@iZ_xZE7aSaS```)$AkOfQCk(3KHC7+|o;jjY`afhr z{GP53-f}rG=6!>9g@H;OU@QrY@jjg}Xb>l0V&lP#jNjjY=CXr;v|n8lJp*&4VD!%f z%##LH0xEX&Mdj=rMQ0!t{gdc{*A4J`f(yK)fftMPHBlk)y=TuzfM+5w>jM+2!3NBv z!JKK~eSqd_;g09(u-`Iu-ix;ZS^_?BH3qH^WPmFraO(wbHo?G60Js%>M{b!E*bU9Q zxLz*93u$Km%k-2~UU*aPb8yaio>gZib&AT8k?)YdtR|$RO<6z>eCxeH*?D0I^{%$7z z5<`xy!7qF2dg4|pW;)<%CTiuJw@|OGBdGYb2|J4Ck=Kp$w)O||2PP8gt@_!UZ3*#t zmj+6r{`7BlECmbCYL%2yzAk>Nyv@%~m=o(}NBC5#uS9v}td=O`j9zgE_b=i-Y0Q6ne zK(nl^wtMr)&MbLO^|oNnIl2>;<_M!Rzicl0GDIjmTS}i-9b@nC*3x}S%x$77 z`ZJAnhF_YssXd2PhO+ip!}zuKu8VlgWT~uZ&Rm?b{-wcX6g`d9m=C?!`!ju)-+!9} zLPjh#ibgyaHDq=h`*5>jv(IhC`u{H7+H+-Vln`%2$yo_iNkd0_zDCx|t znZnL2D1%H zbKh>luqcn@2?b{?<}iR=snpz@nA62tSI{K*sDCRm>xduq+LV^U_bfT!*NXstRC2rA zB{|)(>{#y0X!`HlySP7|tEFmbqjK&7?`5 zSWs^=f{tX&9YZ5}W*;*Ib|~I=Uu8L}O8>$SjccA^1x;&$%3{!!NnA72P@BbLM{qoN zYD^3VM?#3Nj0Ef=xGu7U;FwB}W>uh4sKb`gnFbVvV@0C(2##rIXja8QAD*C@Q_#%e zKX>4Ul)nfTxHCgDde_`h0slpr0#R%;lj_UKV7I#pvmZ+cwy|Ob?#-kb@Xht;2ksGU zg9*u{>RBvFnt@Fcnmcax+Cgk_fd=VWjQz&wE93KNkH8ua2pm1Lu{DggGB@{_qh4KX zwhRbWPBQws>RaT!bp>54yt)Xk!ZD3K!vTFWg1$q5$OR_t3>OH@u|K{52O%clf1f!@ z8ia$Vp#KH*PX?OS1ARF#`O3`x6DWXS+Ovp^*-hq0eM_Uj=r6ocOL^#0`$Ed%IWD?f z@9IBdHE%RV9zEQ>R=bPv^}4ursMns^UL>W$^P8&9xlxN=aySccWuxX^0k!7s-7jY! z@3Ne=K67W+nV(FPf09e-Z0IiA5vbr@X`4jpS^JMzCVATHrVT{j!ye1&42XkbMoZh-to*W z6i$dcKiz?h5ynLuPs)o6#ReQqbhC0XF8HZ2Ae@?|Ob7dKI(W;szgiHDVHDU-w>XY! zp*?bTC=Tjt1EYlQlIX#EGDjUd)1NVD7#)AqGR7|la~N&EeQZo{qw6EFI>g{Du}RWI zbG)ribeZ{7a^6kOr!I+Xdj(sGIa85HST%{h?CVoW!zymc^npKom$@Ok|0xsc>L$?- zk@gq{g+Qc~h;-%S=nsTu2UNFLqIXBzjdXKE=CYNEF7rThyr4OElCav*_U0l5q7c=N z-=^Cutj|Z=2Pg7E+PCi&{%<`UCpe60FhoUiaU6M7h8_gj<2gQz?nw;a^Yk4QEdLAaD}5?UlumybxdGr`y42 zPQ*a|45amj?eV-4Txj{tFo)Ly*_P2_Qw~{{W|0Qg>3a!bzJf_5>!v+F7dnU>OStRk5T4(76Pt_I=0Hn>`Eoc^`#+3n3|hWx!ShUv0qG9uJ#xG6rP z9qw@X5Y!Lp>6G7|&SHkaM&rnZ5aGdn4v#I7mls3vn9H-1-W9me3G{T4%X=)Q-`~SU zf3#)3UFpzw0&?uO8u%a^WtJoq<&i2w&4AxNL|z|OL`V1Y<%r7M9|yx8lKhI!{H^RJ zwk&l3z}m) z^9f@*!YgAp0;!C^AV@L(%`TI0|IJm*3O>s!M@R^(iyI-wM=BlR&4(5DMpCT*aPF0Z zEi3K=!3?)_>Dw#4tv}nT3VvBJg z^F54T+*{LO;Nu+X!9fmVDEP>DY`l;{=z4LDrnz3?l@PM|`^PQ~T7Ovk-;TOjBV;GAj z9)_fMf`2PbyLeNmC>QZ>rAK}F^AS*#1Cj2!QL&`k?inxMY8|d^7aYmnv~9$m5`wud zl0I6ZrW1ElFtIhabGx{LA1)nWmNPUlw|hs>wYt``@2@6|w|xhBJ$G;}q5=)bG6l!% zDARl|5N(LD;V#mG0tP3bjLvXy%INH3KdE z)Zum>29to1YH86!B6NpPOEbNg0p5Cr8|S!O6XS(cKPSxJg^R3n@jPQy4k7aeH8SB_ z_Czz*8ADdsZlXcRT+x|zj&AA1yiTchf=08|QWdqJ0f(T&{rvjP(wsW_AAQaSWc7TK zPd+efIOOCPq@nz9SDl{NyvlbuU4H4adQSGK4ZX@{IkBuZ+_UsC4)NgIVS!^CVqhsa z4nd4raqR3_dgT4^#v>9*Ky_`vcOtw2McPwc(_n2I@Sh0dpi7q)Qv%LB2Lf9$uSW## zh7!i~a-+ek9>64YXzbrM7x7 z6_Q(E(N*%%lEicxv^THgX^LhT@)gx}`@L-}{*#bzNw_|_lJ-TIJf0iRikpYmOAfbp2}?)g{c^^A)E{zcGWv@fdj{v^5&KqU#I0Vjz){5PL4cJ$H4OD2T}d z(xYM=pyA=65$s zE_tY;_hj2UnwfIWvFvYW2npx^dRjT8b+ix0BcnNI6TGSv-Kt0p#u7Ruqdo=?|JV|$9&jRA>GQt%gzqN?-cQ_wS60@-BasHwOtRU zn!ch?CmV|HgO_U#&@KF8!&3&mZQXZMoKv4@EN;m3iFfta&#?pKLI__!@q4w3FnOq= zj=kZ-tjKs8_$KUKOEsg2tnmPn1Jy0o;~%Ppdk)cH8c(XLQ9||h`1kdo`g;5$b~txT zUp1*6oXX^Tv8a_??t&348B+of?(A1jK%=ImO3^Jsf z8S~w_xApzkxNbHVV*%L6Z-LjuS5_r_Cte@cdGT|d`+MF~D6CQNe(8DviFpsB?~jzo zT*qfQhCL@Yxq%&1w&g5M;Ox;-kQB)jO*puQJ)WrryFBwi5zZ1C)wUqj3u2C zJ;!P5o+XU#&0(;)FrHnZCrE^7BWmdEn~X%iS$^F2`a*Ma9DzUf8mTWMnsQUWpDGA#K#fI6)MvbyrO2GDJ zMOFV2t!9g)Ve7?0!Sy)@O=`YTv%gw>%{$B4^R@HyDqiT!$>MIdh>`!ux5KkX3*MNT zDl-Ejj!ypp>+qXoqHm0HI^-4}3o*_q-*qMgyO>-uF{aSNaK>kt(rOPDc39GJj?2_dogw!~A9(R$gv+|w{%@}%PHP;q3&>G7do z0H~vd0CyO0j{7xtob&#G@7t*spSxc)4jou6EM zysNz*s7aliRy|5{Nc}ll%QemMTdK@t z3196(?E<}+@3;Deo!Y(q_wNTM^}g=YHMCeAiinIZ_Im!5EV1YC-O||KGuoM~^sKk9 z@Z=a7o!_@o_q}8=9F!EAue_AlvpYz9(=qRhB6H1B6dv^7+84I6lD(xs~8wSUg#a1YkXFU9=o&W)}hlAyC-NSGdz@|t>a8oQ0Ym?p~@ zkjdegu^($1Y!?#{>Ly_SK!0F7Lc6pY?JY~(DNz~l@V2Mw*XGCJ7gQ;cT;Y?*WgAmDp2N0q?yPIJl3gx+ zw^cSPF>(X-2{x)ZS{iRZNb(4!yw-ot(eGgI>3&CciGQMs^1jbceTi!!a6vp^g@hy% z<2)V+#lf}ccyOP86$|3}DbD+M&-@({C}j#Ha)1=Ji3d6Ta=g-@f%PY#u1xF2EYVbA zQPf4ddmi3@C?}&rTPvuc?sMqk;qL~G4t^D%Fk6x94q@0PE#x#huD-hm`QDsRnj|bF zazGBY30eDGEfba$Ni;=B7xjs5ll_y_@e>8Z59Td=e3n!xwUGlxuuUF_YjT|TK#w7; z1R}-HS3s3A6**uJ+Z2Yl(uTRNipJIr_4whCY*D3LMh^JHHlf^Sao8^^313u_zo?{t zQOWwElKVxa(8~x#21-2$r5@YIT(Za#ikG90V3P#kM+4X-5p41?Y?2r@NdlWBg-t$z zO_IST$zhWeut`eTBo%Cu8a7G8XiW&&C4|@#L3W8CwvQpZk0G|i((K#B5L*(+E(yez z6tYVSv3&yBeIhMjO$ON|ixr57bI1Q1%OUzRfVVQ3w=$Zy@*8hu=5O-&{6Hi|!^ky1 zKMe;X(>1G}Ni-RK;)2FU^OTqjEGL8SOzU7%++MbBFDM3T_7w=)I?r4%Ol9NDv~Wa%Usy}Q%Xi2qd(DL~$c#h)m^-!V)5p!P90(5DU^y1x2d{2F6HyyN%!0sW&+ z!|mPNjrKbnL3h9Ltck9%Ayp8cR|>ujiR8n%qLw%p$=8mFus05)l{!-bx(7Mf4$+Q- znEUEi@bJUqf?cS2JtiXBWe1U&h*ayU@E-ayDt^g*rXc=jBb5tNOK$L4jr_!ade2P$ z-EZ=i|H4xV3nCjSx7i*rKDYm1Eq^*`3O^qFAk=*J`U%HKNb3XL8^+}S#zF!)F|`am zDFJ(vt=I4<43i8F5nc?@un@Yj(XtdXw1WBNg5p`Ld$)PcK-58D#5nb?r(ya1u$ABv z#1?{n&_WS+aZWY`)gR?W$-Gz+H?uw}*%cbKe3ty7v`ZzZAV2LAbM*YhGCb|*)r#{> zH{4N8nH+5ZhEe$y1GZ$?#wF$yP^T#e6lm0zP*%!NdWrlzeCk`=VixryA4}gh5;^np6Rp40 z+xs_>ROGKF4fkF83=I;|Db|%A`o122ha7$1&Jkb9q3Dq3dCEh#mLsExK3e;l{U-(^ zhd1}6YLxXR&@EFHVOSG85jNwUSrRM-&!O{uhB+N_F z2rE<#%gs>u&zRiHhQoW6c0|J#^~R*9z0^0cN-+(`Y$(40TXdub&d{CfU+5G6`?^@KwDxOeN$8J zb@YR2s?0@L@K$#KhoueLapM3jgbtB|b$_v(yNSs>(7VK(T@0mQpd6=|(6hDd2y8V4 zNvBFHo9|qXI26&I_%DVDe zic(Kp7b~Xw?VjXz?3!k;R9Y~sPzD_ro+fD-99|H6F_ELEUs=$7u2sGRiEob3CAjGQ zH9EW%!d=)xp~iAc=gnL9oBo$cyXu$EG|jg~CTnto8Ys&=R$(e(zPjyL-+;T#I{DZ& z4>J;XiY<&vRXk?d+Z#4A8JGeQwLpYeUJel{mpV;JC6JI>Q7A+uGmY&-(FMwM(sX2ieNY36s&A*TkmNs{e)wP zn&;U$Ygx9Emq#SW1gO$o{T^R`^)U1I=PkPvnv?mDH?K=_}!-;bj6f&jUm9hKdgQ=!LtaJ;UcN@k6w}B7 zK9~hH{eqY#x7m=7(?yBPt7%c{!na}ro) z^>?<~p5^h_|N166Bk|u}OMSG0E?=d3r`;CmZC6Up^I3~o&Z_Te^nqtf z#prU6Z1YOlcb}Xn+r+LzPHiMb`c#?`_rDi-(C4iYP;K_K4S%^ zz^R=z!woGjb^KE7I&%JQpe!NVQVguVRmD!o`R|BH0QOBL&ANAd%JV zxEJ!w*Z`pnO+s z^|DoG&S;;)1irT6lSljRkt#K(8F zFG5F)Lm8euMgPLd`1L`=^&yqPyczOIk6&a|IX#^kQB6GBpazk6jp(T(Zg0ntC+sCh zU7qwbpnZqV5#Ub&R2)^;llF~#OtZ0E@FHW$6y(H5R|Ep`SKOez zafjWGf?H&n(I*TjbFJj#=A6|QlWL_|>EixV2CGB^_}?F+;#$S}&k#BKL=ieh);1ru zi=5X#X&L&8F8Yzrxt78zQ2DbvryN^8#58rN~s$Lr#oc&A}4zf^M`rbHJwK$RB1TU`&;KDjX z2Q=Cd3l{y2`x25%(DPL}M#D2-E(7tOJOhng#I=An!&N{<)vJEp?a#VgwFD{624QqY z<3a)+d1kh?&+OkerYISvJ)S%Akx97H=&d3CGRpxeJ6EOg_KlU=Ik~JEk(W8=rMaE% zawsW}G4PeNMiZ!ZRnwSby;72X*{&5GGk%9Y z&I3vHiP`=>$lN!7FmD(Jt*A#D=>lrwHYWp z1qHEx1!hpt|5soI1)tS?l5F;GL{C%i@$}BUODTJq%?TMJcaHdhA0mDBMTQBl{rlsn zl{cY%PVBOlB2TgfXz@m<3BUXx0M)@VOyQOu>?-a+7pKKjr*{@w6Vl0+qU|H!4t;ws zS0*kxH17`Khjcet&WBFy%=jP?fJ6imF-W8@%rtOA!#8vAeb%)tr)v6N3AN}Gk6Fff5KG z6drMPO$FrwtrBR8G$b<36(~k#LeQ8fXlw^GRxcwR%1rc-DPEE}Na5xqGaNK>|0%C5 zUnp>30*! zXy6&JEdWC9zXtvZ96%@sLWhNMh(ywh50{@6cJJe1m`l0TVim0MUs?}RX}ckcYectm z?CQ|MZhw-$s~t+BtI)%e{WpiknNp%?3{^cki)ZZ~itV;wHd?6A3hI-W8?g6U@%FeG zy3KZ~^CHXdGALlDt$cHgzji0ZZc=vM9Ln)Z74e|Jnnc!mtq%Q1OrT7#L}xz+-UcU%NGfO9y@87&;W{UXoY5G3 zhqpc*acmBeakZ_DIt4Z@sbdr6v_thc)I%musy7i{3+zs2K{?BrMf%@GvqbUA2TD`c zbj14*o^OkPGH2zirr+x4Z4neJ&r_AUAYw|j#SZ=y1^aFMAg|CaHfHNoZ}KIM%}^U6TZR5 z)L)x9ugCnlhbH_G{&Pp-;)_;JPK}dCYf`WrcgGQ0|L9GqC*R=6T6I4DtnFP?qg7x9 zlzMQsi)xVOQuxzOFcsImT^cU@E6UR66ukOI&+Mx_$_lC%Z-0$!8vG(ZIe3<{pGl+t zw@F;TF)^g-;+RcvHvhItb^Hx9AczaBpE zBPbYAPIrw{&Q@R-th{qDYSEU1+MiWR_@-r1Va#Vsu&rDB&rNkn{`ui?F}XSeZi_WRa5}Le`69YrHyAaYTiQJ43|12kBP2 zK{};7MLJa)q@_zrB&3w?k`C!^>Fx%RknVTEUTPKVaOvcUXE9tk^c?S`4E5ExQr;*u z;a76S(L2D0n}Vi((3JU3O4+C0f$w;5*Lre((t*CgxU&36ZSb+nthS2Rv zV!fCdYhOw16sAukqHwDR_G$wQl`c{~81_5tzzt(i>;T1Q1EPueOZE~wHR%%^@Z6Q# zA=g5+DeEp$EhGb?N%%{O5<4yF6Q2=NZd|06DFz-v)xN|Wyim?)B>zER`h*T*HOBzD zJ=OCAM26aYyySs+wE^@aD~UNwp&a9{{0E=YC&uK`uB6qhb>a2pw?f>BVy|S>tjz+; zSY4#-kgMAV(92Pm%6Ne54+&RtYSycPWg;$8hFAml!RYKZ{{f}az)b@CIa;~4VTRhQ z$n}`(3sqc1#xQ#LLaimV^nH3=`jq8a$bSbbDE#>h`wfBMx=}7YOh?6@~DBl z70zg>g4#(xg=FL0Tk_dS2;4femh`5xz}w9uOV5miG>p8qeQUnjb-xxIr>j2E&YMh5 zG^2|p`i+O*nz-NO{81jCpDJ%GwG*ESAFxzP`}j?ZSqBugM_hkaI>~MOZJ4sKbbFxO zxi!86FOU%@+~75yt?2pmIvq+@s_swimOj$6DSqTwi1r)t1ot)f#@}qj3uPiM5A&n~nB-^Nrm<`C~^7 zoCyAtZ}!uB_^EC!Xp3D@n>jlVDA*t<1F#5r*%;flxf_D^wc&@o$+=jRO5T zFA~<{JT0(Kb1xV@Ga?@jtxfNB`oe}w|4hKHYltUc$Fqx_tflWi>B=vBnm56MH#u3s zOXi=zzHMtDK9wuv_smG6SIYGpuHjf}a2P~7jroO<^YllT{!@cvK77EEJGtj>C;Y~E zWDMd-g)wfcz&p%S*a1%K7-ySB-|4)w9k$BoBcqKIw%BeCyKO5DFCQ8DNP-mtmq(Ms zGtNuoou=jgl09^vPwW}#wyL!gYEQbagHa^d((7F0|#(Whxt1PI@ z8v$pxBDHB$-t@#UamC8ms#Z>aDq?$Nzn*Jsb$`>@9C-u3nv08YpxOz%eA>VbOkC50 zUb}f@;9gqaQJM5lqr+6LO3t{KOp?5FkG@%vEWI*~M!7fni*)&rjat>k;rO;kmkg1- zoC*1ZXc^4}WTD=w_6-MPNi{4{LL~d0*W0%(%bk`M{_y;j9!8s#hzq$BMB)A7DD|zM z8!PLVYOzRsKk}F~Ty@)6H!R+wEWH@#M6S;e$0J$7J@76$lC$Z&9<8bHD^{UDnh2E~_YAp7|SWX_m)H?wE|$ia|Z z&>AlJG?_UA!gS99_+e%@6}T`?3`jRdT^qJDz>NrNYkj{J0>>i}0Jnt#Y-VQ92lOMY z;mCX1WC8x{D1dAZkY)SI8K7Yi5oAdKjneW-+wRm=0$#a_f+`_^jTB!*0R0#30E{zj zLl-Y>zXnmKg&`2XxOoFihud#qkWB&x5z;UbtHn2hvL@92s0kDed3b0V&3m_BYI)DTDd4OQk&E5c= zxiQXeiw7M=QP*L*zzX&tu-6CR zp_wgYaQ0&sWIK}}OVt1Jus>G;&g%cosC;|N#M&CLsOk&|Lk~~jIrYDb$4H;3zzYVH zAufvg$omh4bR%&#UCjWF|L_OMiJUGKN3^^6$sZHa}4G-rVRmLl%_w)Jl~T52^X@> zcJC5IaOMrB;6CAjsdl^;(4b$dfui}VFr`P}@zW0{)Nl}ue#UiPEO1xgA1WCQt`#wO zwf=)9H|Xm%!f74Q0(hRM3UN@hAOKk%DagtJDC*q?<~4Pv1^u=SIN{g`+z}XizgDuQ z2{ao1TQAfg^88i52RK`124>ZHz?lfT-zRb0f0X1t_whO=n6eW+D2ZP~vhcSV0QGe)3D`iX03iBv3B8t;53qY2$biAyWA_eF z)V#vQK}hzPO@K-MQE;dga|AG0CE%C4AHW7V)dwJ(FCfd%JHmm2E z*8wy>`T*XGfzT>vwHABfW%D_uq4(DikY;a$SDpSZUMC0-#1{jU0wm*seq2`R`ynY z1d+!tAAp;e>+s@?G&r=!kpm5YJ^KX4PNO6q>3#`z1`Pw?c(Kv}V~0`)ECxRSV=utQ zkokZQI741)z;J4()185p`ehi5eDhi+PwWv;W?xJ}N$wDIq5a3JzCHojr1L*Y5_90s zm!Fh?S!-nK^?SJnSYeeaaFN1gEAqq!!F)1+yDI)TVf*tlusA$n!T^wL1+J^yAvl|= z7=sJ@gC5KVao?D%&piV0A+H~;)|BEOAjuDAqvqeplS+WXMSox@Nfk_?$&&G=05Ba5 z2EOB~Np#d?3N(0?0v}36z<}*)K=z0P*ub{{=hG_!6CwKoAahsXrf?>g5i?=30;Z}p zMI8Q!9t^!V-VijF`(n~jf&JlV3dX2B4-{JXfSDiO1@;N;fNGI5_6fCgu-0_0fkY_) zm3v!Yzza8dpw$E!SnHFH^8^Gd9ib5G8Q9jh8(?4jbU!euozDQv;~)v>#07VRaO^ig zU7swt7(xI({6H2d8ojUr?zA8F0I6E9(~#3v4n8SvnLOlc8+5_sEmr~`D1Xd7y8)eJ zIwYy&eXGG$JbXm*qBz_80F2lHPp#M)? zKOSQ$xCr0C2U2gb5|FJ0cdy$aAeLUNA-4(G$9F4WGn*Cyp_)Q4Hv<1wh^ryq&96IR zPz(h?`{sKsnx&*4Kw?-2%$YqM&@cv8NJTA}9^FwOU~mO{_7CMX_;-ie0k!L3s0ap3qMY zU?$_@dMat0{=1NVpOL$%id0iM?VF*>XrA#)bd35A^Dl93{LjIg z1h??V1{gEq)J0>}UeS8M(Rv`zdLYqypkTZylGaF;$_VNv*e4dU zq>>XY^$x?02#ECj6Zs581pkS^7bCuoby09=?WGJ4BG^|U$QNBmG^64=sHb?{8D%fj znD;tXD&s3?a{AYl*<-vJd?d8=8xmj1>Xm< z_);(ZZr9<}7?ch3AmMQz*~oFm`@NV8Q4#%n?s@BIFQo=@b>9!zdCF>?iW#n8;bH&X zQ*eW*G?Sj9RsAs{at2C6FW_8PU~#Sfp}gpq7r&L6NlW3PK3;*-RA_}5wlZClj_Bh( z*K`u=2Xi5}sb0Z4h^A zB@*CHQ)!A`sQ+!``fEY~-4DEU70-9W6^(=Y_;(Fa9k*d|Fdt2Q?G5)A73Ek-nF!vS zR4f{1ju@188+k?Jk(*kC3JD}XoknUJ1#B`krRp6nwqVYDzf`OpzH32T8)5(NVEcO+V$OIzRZpp2`rXN z^el*jaw>~Q@q~8hfi8nq8g}?Mz-^!q{<-v%Zw=NJ>9Vlp2i}aI1-hgqcCNai#XeaBePG6RZ z5%uB`Q`y6>LO`y0rSRdPnQ`x)ku(VuC=M)O-b1B|owEU<@^QTAMFzT_yo%pG zmAHkUvs;>n~|nWOyTK z24Xv2Nql~cVdviB^oV;wEl|QNs$f3Q|J4=)mx%OyyBl%bdjOipk+6b&O=p52dYC{Y zDU}$L3yA0%BmNblYS+IB7%zsrb2JN(Tss2c50qa3n@cFr1L+7A}W?G%#O!?xf z5R_BP81rTgH3s~}Ja)4#HnJ2Z>8zf(^5HDX>^@^N*~INIig9WwR>u=#n64q9F2KAf z%NnMe5dC_c#|5IDnI`e1o0-;&&4D4;dZ`R(PzUqJV3Y1kPwc!5(op{+^ZA~%-@GWP zh3hOiE@zEX`}6&T8TX6_PGU|w>?P^`c7?iVqInS~KAW^(n*-VvZ-sf0fXBBS*vBfY zD}jj}=g%7IX4to!q>f{k>Ox8PQ2%B?;Xn>;=6i$<_4$CStD$&^Q8^YLs^NrLnLs}! zz=PQ{P5APRPv@Ej=ijgL#_KB++^-IV?(25X3ktkq=1w?0V1pJmvE?ph9Xb{+*oL$2 z3B20xe!ZD7y43jodcn$=14Cr8VrVs$uu0+rn%xv_q=dNK;K#1ZKL{=@>0c(J9!W{> zT)EG!e!aiG^di0E;W^*6+!`Mn$f7=rYDe7EFDt9xJPGpbTbF`G0w8Ia#fvobgbg$qP(Y=1P_<=Y_QJlE59VrKR@y!*-lmUv7x9dCu z%~ou%uqYBjD*P<_v-O^75#^^WM;xf;8$|QO$$$=eGIqBWYUCY@sZ6lNzYE;M@`)$z zKIRR$d>=48XDJjwl>`~Tr!o6u)@?>x69#3j{adxe#n7M{%nE0mv>*8guDy+4!3kgg zN-_0<0E&aQ`RVMb@q=mHBAP7OTfKF))_m7$$J^DvIW=1`>M+I!RWfcG3Ny}sE z$bw>`!pY-+bCKPCbGMFzzagGykW~Tou8&A{$Ip)|7jqUzAML%Suc8TO?Z{4M(zg`K z+@=*C9m=mXkBa*W3?1eiFC3@Z#ma=*TU>rMR=>-<+9&RbvcnwKt7K%KpX#f3+cKDx zA2zo7POI)Z9k8an8GJXleoX)RT1PBzCCO26i2ArtTkoJsXXUiywN2*WZ|;B?9j}ne zx}m9Nx!DTC^4Z*b+u5fI5nEoEMyY;%$!uLWeb1ExvAn&-m)AZ4ufobWH}0h>{x5sL zoFk#isT9;O-QZsdyblV>psa5HbOKrt+B-fSVBW$Ifan8eDZ@>Oju;ax7C#|}a2J6~#w;^?N{;F1_Pd?){a#|65G*9vdsDh(2xhx-RD56Mn+`!FSFwgN)Oo?#MOs-~_EDSbX!^g4B+!brRx$ncVk=}&>{ z`0j7S<>%3FF2{gKiysuH*fr4m*4%}QTaqM&PJ#x*U6HODE}Sb$h3q+ASlnRG0Fu;Z zghEI!AzpagV2BqN9!8uiG7+CeFDPFLAP8;zH3p4ISjVf{;XM z1oGa#dvEUi-aPQVdE9&RocHEG-k^7#c-7NB^hGB?$WOpo5B0{b?=2K(Wv5e&J(E9olvDKMDy3%a@P1W&2^rgOI zQujxxjgJxc(Y2ANPe>y!Zx(-B(SKR%v+&1HxbU(gC1&_1Y3ko;FGAV?O*vR?)MGg5 z-5Z{B#(tvZE*HBE)b;1;4);I&TAu%R<)5sb&8hjHX)pS}pL~*bh2p3WJz4&wnu_;1 zX;~LM0}yQTrTW4Cde+7I{Jfa*lj+zM^kF+-=iuq@G^n)&JJ;}l;?aNb&C1z)^z7UAp(s|AtFdJ5w8Wd zU%&J&DcpUHhX4hKMMrr?m>rITu+R0L=4}voR9Bq!4)WgX4=to@6{vKAIpQmszIxB; z%H5&qZmRCnRIYk?Uix%IxaKs;I#yy{$!qeLBUsyKJ9GDv<;D6!DgRP|Zs|pFn|G5; zn;gvJkk8exWTv%l`q2BBa{IS|>GY^u5_T^0gM@1va}KYzqg}nsOd+#(?J6y=%*~~v z*^P_qbQ|kT-2(wznLukRZSmy&DEikVd!_TN8#$?O%$1CMU$c_``|sroW-b+FYoZx} z;wIgikZjvPMRt};x7S%tC--BzPL{m-3Z0^6B0Vb@4(rJ38Qric^bo$BZ(n>dG8E)q zr9}Bk#p&jJi)h8j(A(1fyG2te#Yo~sC_Fe&IuR~s5~{|3&hoMQpGY(i@%$%3TPa2P zsTIQw;hbd#PhuZyOS?$=R()=}{zsaHiR>D6X9nE`OHRZ{X)WJytCFAWj&|-5cjdEO zP#|v^DQ(>2uGF08xL^sL+!>Qu3lS#p!r#~0QY|uz6UZ5p1r;Qq!W@m*jrW{kW^o`* zBaGHjDkHjeaQfKO3bxn*i+^_6aj|iFy5Rr&cYetDXPA$V&-My*hP4mX)rVeWRVTHl zmm~=}bCw^rYqYKCM3z79-+PA&L@cq4vE=HKc?|Dx|NUXow2Nn$5ZmKDD>qng>&Cx7 zJIr1^JJ6v~nPLZmml~2QI1uH}3fF!zxBOs`ab>1x;$6@*dLUapMOW_ciyx1l#Cx+Q z_Ay2yAgwUKY<0LiezZkOcC|3u*3Q$*SSs(T^yq6+TiS-WV}0A;_oQd3eanaNc+&U? zJ;F*Gjr~{HgdgF=GXZJF`_KeZF+_Q!35n1tJl#|) zKi#Or-b3F-)=~nlqege2T$n%%71=bW?|<5 zn}=bVytUuxbVugsaQT-VrD|%Mycf9Y8zx$Q+THX1#{BJ2_0Pi|Jl^l? z&Z_5a`Wmi2u9alo8g={EM1-lVN zEK@l2Y5NT;ZIA5ydJNV)7Aa}!lDT0|TGz_Xv)0 z{#U(9LyK5n@TSq{f6^Y`qOcYc)qWqx$H6a8F|XKjbH6nd4sB8Yzs^Gg8Z)tRIpwPV zD>n@NPnn6#XM}&<91`Pxs~XFF{soMF1V=@1yx-&I4q3J^N1L)Rw{}R1%lXVfEA7@2 zg*EDYUSp#tcrl)p()i+{i4QfJr+ZlP#ViA-wY$|i_9N6HX zlC1f&mOic$y+MA}-CDr%)7AH~rVpyLZ5e!roUPo$)E&a+hdJyE|Bx(P4`D8x_)EW+0jL1;vq376pY4i^-ISxn#c({q>EH!F!ig z=kPmQ_o+@yH+&R(Xo1_aVTXI`NGEY2Vb}Px8daKkn!fNmy1=ht#gxjp}puXxeI zaQZn7vOz8C_npAnnURj1=DCL!L~t9KDvn|*X(Igi+%^F9yB*Qn&XKj)g|~h95YDdOBA18H_p+}D@)?^Ph-79sA@w`K^>y*D z-Qac|cWmEX5%GwaBrV|_4l?)m}!C=B7(jei==CYP}xACca13nZU2_qPd z0Oc7I76aSf!Y71KNn{K}Oac^QCM+%u67!-D*@4|}r7aL~2v8`QutYUTe6zXWCgm5N zBZUT{VFcq5pwKa4DQS=-XLAWo7B0Xche~2#AQBRwu(FU>0XvfKHiEi0kVE0IFoH=4 zP`Lk!@FfpdaW>v}Hjvd_Yuq)Kf&&6GO`w4HlVkBgrB5)_m$eFQZ(m?QKHfy5P#f!f zK+i=C>(NPbpZD#)+Grf1I-&f{t161LiD6s)M8`YdqyBn+ZiD>yz7oTpb3$6Crowqp{6W-L=DdMI^PY0~qR9s4DDBXv( z_iu-dcN4_ee*aEoELwEC_uB04r>pDN)y|(fEsPAxd(`cdiTZ~Y&I_=-y6e9uY;HK> z)55k^){VG2c6X;&L@z47>U?CgbKd_kHK&ba^VVn5yRcF}-I20pPGH%ZEWNedzHwx7 zvzmT#$@=5V$b+&(orYTrvKx5pvJ#yveXV(AeX(Z-)?&dPHf>S|c5Qc|2GoVz2J7!> zYun9E44+E&YGL%!e6Vp8ee(5LgH&HiM|)jw|1S3YKfgqxqQ}Aijm%5%J~GZ z{p>D|=gVJoWew$2MAp4a$g(!j5;U`SrhQ|3XY65Ds&2e}DlM*WNN%W%$GVC^bM-Z! zpPB`8$(B{&=)PIlx|@9HD34Y6qqT-w^!eRbPTneI)0Et%zHK`!&lB~k7_t8vOVRvqewViRVZ$H!?8ueg{-y$1IA%;Urt=4~7%N}mv;7$k zh1)TceeIHP>B<^^mnk>Yu&q(P?eA^sl-}c>Y=s>4!ui{#vD5O0E(3p?rN>4dOC)Ul zP4=h9jAH3OBvr5O5^oU*C^Zp=5pJ2>OYHkJ=t_1Nqie4H)Iq+xFjROFU=YS4S(c^} zY%A!=p3(4fGC>S`fp@i)m>EI3iyw`LP|K1n`z?ncem541l~nTJ^E943Nq#HKV4fEvPEHIV zDL7*xTz|SEMR=&w(chtjgu?N>04H)Gpgl^WD^??g`S%DK4}-bCrP{##NOtU1isFj{ zo;;~JE6dP{ek@G=tQ4Z^S3Df%ewHRcpc4p$0|DBe$9I;W%f_*=6H>^y#d-LF0te8( z0on)uDUb#V!azGNP(TF~yaEdRfdE|GRSG%{P#^;Yz5oHdgcKUkE~jM~4Vs@zcF_Te z*Fd5MNPG@28&|>C&mv{yM&wZkDzbr$3=oI|0z9%nAOZ*wri75Pvhe5vAF2i(N6bO{ zY?uNCoj^edCh3M_MbIr`AE(2cXKRtmkMisK!geP2m56I>-LL9< z6gZ5++tqF|o)W1$xVChUr7zrc`CfbR+DzLUucm>{?UQ@a?-TEfbA=1izvyMW9!F#t=jt&)5j|s zXt|>}p`$VhGGVDS-R;nwggKt5z=%_!ta6r@mhq&PyV|M57w(NdX{(WoEgMB0;*dCb z%+djbDcjQf6n5Srt<=u(;;wuf0u=58K{@1`b_a<(h~HP3Wi?VT7(pI40gu6^I^P!| zYnXh2Io)3VNLfQIlvi13H3^G3(Sbp8IG7zRP*2aOy&T~ZV(!d=Jwn$j{}Ydfpf9$I zXKtppd%F-J?Do#`zt@W zPen=a73_z_kDCEs!}$Pe&Pv^z044+Ip<)C|@O}vixLosxNv~4uQ)h0z7tJiaxzQ^t zI$4zO3PtgIlSQxe^I832&>Gpz`$7vi%DRcZykBrH2e}HSUY9bh@7!^Jaw;mLHFXqH z{>@C-(*1^IlH5`-u-mT3+_Ewzv7wRVW%1%c@0_Sl`9i8yC9&bgWvVhk1N&fXK3|3L zTh2D5_Kya28aSrHF!+f;bI!JBTNl*rz6;j;7)10wPf2C^osYP)1qanWJC+?8;N4sP zS1{8AQ#`1H^$Mybe(LbLc{P>>wYT_7-YiZ@8Ph-IcD|T;f93ZSFDz`s?4i_AHuLIK!{XHyZYoY<`$?@P6l^AcmvbWftH-MSJP*T)Fowe+F2H+6>CAK9cyHHL zomnBuQ6x;^`D)&csD4OjIX)PwZWg0jUf4|F1hOl_+T z;)50+d3c$aIzBx2j8Y;~J9&p(86pGz0_1&JQo!lpETRmy-WDBrg04 zu5-5go_I`KyL78xL1tgMW3c)uB9)3gy;iKnuTarR*APD~ed4l&N$;C&`8?e!`#aU2 zcPsHOm#K9(+jWUE+7IP6Erohhg~qd%Y=5l=4;$23v5#!`roB@5ORe50*RSi@tC`h~YrBZ#TQS8*c`VS12f~fP%jN@426B{Sq(6 zol-Nm$<(H6eDiGX@4ZuhP_s?)J}Es?oX7mw<7BZ;T?4l!qkNltc5c<_hVJnfoRTjD z_7)Yu11CF7&-hkJ(3&FL#D;atuJp$?smIQVM7-EMein|O6X(!`lk~J9s6*Ka)*mS1?RYdn7x5~ zisBE4kqHWz*&Eo|8@SmUc-b5HMI)>dU7QkKJWf?y>u|^?aL5qclLaaK1s;eC)B2H1 zTvF~`W>8eOFET+ZGQlG@&o}DD64yPF+0`UH$((W1I$( zR@oai*&BA*8}`{7j@cW|$&QrPm`10<8mA&NjJ3!dw^aLl_Ya+rL>HoD7pi0zreqhc zWEZ|<7qMg)`Oh743B!#b`Jf>6pdj6#Ak&~AtDqpqT<#MjWJpF#3ADuo1tkRqWd#Kl z(%7gt)^C4}9u zgO9rcNcYcO{|S4gkQUUJ3Hdq&?RaFy4BPtgnRuA)k~SV%u>>K9bk{iOzx`=;piAxk zpbPupU3RYV6tHcU04??yeD<5Hv(A_eNP`6CE$9j?f3(-#t?OojCvbExAICi$VIKY` z`C^CPjz>*Yj(IsRW6L|5S7^O^PQ~<)dN|o(n+?^szTU_U)lW%bo`hoE2ddivPVO%8 z75(JP@SEee$*|dmFV=0Xg4h9l)(PB z|Lhy}`H{Mt-eeH=kaF07QzKY5P&dQz*P@uGqUM+LTUN}ZxrOe2{#vxSYoed}LXqEw z@s*7qL=wF%I_d?N6K4g*Z}s0mLuU5DQ>Zi8=S0>4xO#^qUv3?KsfFF&U25(T-%6eR z(tSQPUZrKdYUQ2zOk`uB_B|@)l|A(>_aV9L;cenRMAiIV;KuRs>s5fsE7`}Jv9tA7 zAD+Z&_11RZ-$>9JOg!LpIN}<*lfMvGN~#!Enzf*7O?bRH_1(U!eDxMx_6~A5GdO8v$;H2M{I~O{fu5)_0LH5teROtkb9{TBSbLDTN@$R&^VhmNC z5WW0Oegj1}A8yU`Mm6X_9o4Yu{+>K**sk#fL^f=PQ|DuLAs+e!H4JO;QeVZK#+!Hy zA&C2*Sdd0U_jFhK?XFuR%y_;MGrutv61y~@e<DW8dXjjV8DEmL>hupSzRo-Qs&~*n_txu^4n`zZ1~3 z3trdm4RbE{O3)p0{0I|p46N@ObTjG^oBqymFp?^VscUD%I6mKq{h;tClSd^_X<*i~ zmy|tIACb)jo?5R}q*ve9mQl}FESRlb-9Yd0$czt0yl+S*jx)o%sM9xfNl|E1zo3s_ zRnW-bBx9pGF~Umbw#lw~$`xARwScFpFs__9(VS)7Ep@GDu%I?mOF(RBu%>T=-au-B zE-u_Pb?@S++*|T0aiE8m&%RGyf2l%YvUd3Nr*)sK^^(VN_Fn8xN%;B2+{dW0qCKs) zREDK(5!Fjvaoc7scE>%J`?4Pg!7Ygn6ruVP(`b-Y8bU3?brWd0%U9C)2XwxThM?bP+X z;gtNMEjQ78wK;?Fyh2Yv{iThg*`U(nP<(l_?I1djp-aL8o8Kyj_5IK=n-`(G^*Ngv z`<=&*&3)PwJ7P4>ELQqUIUZ~6TuUeaRLgf4=qRiSlQ9XkYdWjH9 zfEdb)7^;OB`cYcfj2RWf1{I?i6=M$-gBcCO1`VV5%g_|wIG3`bR$^jyY-)CFW_E0D zcI=Pr*plqnitO0x?AUtGruW5|7<-r)%vcyUSQy1o>==n-*|AeaY~_+K>R#)62TMf+ zOQi-&l>|#Q2TKhFODzRU?FUQU2L~a43c}vFjmS5Y?ZlDjdZv-W{watro7)EtIT8*z z4Gy^!4!H#mc^D3P84mdX4*3BN8516vyi4 z6`4jAxkeR*Mipf%2KNCx@&i0FCIT`!0x~NCvIqjQa(DjwdL)dwkfB3#0yd0*I;B!#5&JhX;G_uKhM0uNxdouRAn`S8jhQt(qT< z@BG+hDNSRqD8^ZMl%N!R{3&d`V^b%579{NXSIqUIigG_TlW{c1ZMV2bia*(K0Y|%m zpfJsFEyWI}Vb2jq{Jy>EYFp2>w4&OvV~w8-yejTBayHYk$j<& zyvKd;+5nZTBgSgfIv##blPmT;EN&x%t2+x#sH4sYL@6z} z`tPE%M5oHWQajFJ8x$Sm(WUtGebrzTbmwA3p)2)Sv?%#z+n~d^YF(-3Kc$dSy{^3a zAcgF5M@wD8LzKj0#gOq@x#uML!Ma_*ir!#kv4nF(=$+k2(~#;;yF>qs z-?sivD@N(8SEA(3qX>2JL)zvjV)RZ4>KI88Rc%R)x0AoeRqWZ<*$%5r`Ut2+9qBIE z!@aHkF*F?M19ojNsTol&Y;IJvBkEm8lPVewN~#(S%PJZk6X1LRq;3@rfhmyIz`2I124f2PzFZOdzHl=7empRe z0-T%6AK^jUCj^}3*9>Xmw0pAXE1O~{99h_W*TQaYbjOW<3nndkOncjCc41aPjP-@Td za2M+{R2nd#7fWV2BDve#+rLamV!)yf-mbSN{`y-WDdsT&{wvo23SZZTZ1h{MX$h`I z4N0!XPua29AA`E4QG&XvF@m~C5WC)1hrg{V1*tClZEPkFTDSz4-m(mr9z0=u&|3|D zJgg~K=(!{sibon63cAhoZ zmegCclTE%Lk)FZykj?XZ_p?$mxI?Hl4rTE_$n=M#0&}i}HyLj}(d)O>5lmI35ll|W z;Y?g>G6V|hS)aetVtlZm!5~o7zsEx^(Uu9^R*?w|ieNg84ri)D5;IOOpntu=NBDZ9 zfhD)|2_!R+SQ&CVXTh-z9AmU(0+m%|0-HejB`Al3#H*P&CKG56YJtZDw@?+|?XlsU z_;Bbz0G@vHVbqB4s^LD=CJY_?Ie1eG4YvqD4@HkLkoX;^_N--VR`F2;e~CLOpK7Y} zamjUtpEl(bOL6X_ZILhlPTfkbzp*TBe`mQ5yC~t%Zs&0mYBQd_>Oz0hfGwvzGI;f4 zp~+Cy`QS%NGj57mL{_@vRNsMWv*a6Xu^{hc@4oHgXB5(^;qa?kZHFIA@|Q-n6a}QT z2G#eAG_U8iuiCRU3OLvcl$HncHt0mc8CCa_yI8^OygyaQfJ?GSxjor1P8L-Qv87Q@(8F1Cb+qx@3J7+0IJGaf2 z@~7SUNx?WEB0J=g@%%&tUvkZzP;w0&Uo!KxY;41tOl-rfOsv2tNc}RgNscrF(`jhu zKR|!)WVG{hEhMiThM-mlmLSoLi0750%okWet->rpt<9h&6x4i=cs|}G6YGI5jw=3Q zKqQ}Lpe7aVyaMQOPeD7!0Xm34`4vCj){z$8mY+4vfMGVxz%Vb~7B=v+P5^I<9{9-( z?ED5AzeYSi3Ft{0>bx;}I!!yde022Gv2{?pUS5Kxhv)w!?213!5gtM3;CGeBAwtyy z;&crix4FI2*<>2shP3XAIMjSHftv%n%qRT{x5lq){&oU+JrQVTM?sc-!hejuMk5}O zfd9p~^{`VWdWKuypcrj}gGI(tFv4s#8Fp507GE$81;nF3oPM#LVz9Hr9 z!25ZQax64-gtxv*b<-dM@hkfY7T@RPj{={mRe9^%%lPHWY;B@MDf;V3*E<*7W&fM! z$SZrz6TQ3SQ8DhOQ1d(Yamb01w!$T&WOXOk_}Y0=oMO9KtLej`%TlkK-)YLv<d$s5SMihX^)=31Nq&d5Orp_4I>1lryj2SRX9nYBgT5k&pKVGJfw3 z6c%&dAV?8(!Z>?Q{--za;p~BHE*x?BZ#ZHLX*eQnRBzxYNELFMox&hR$Zbl5#tEK^ z&^m9>gMaR3m=v4U`6HRZ;)=8EOeQV+Y9@ z7(@id{*$NBn^T||aP1Ae655m?j~1-L0=g1taau?5P>*RXgw!cK7ZoV1H@_f?^E^XU z1IHwia70}&5Ax*Uh}dBKT0l=?oS+kdJjKGK0>wfaEzTw1GgLX3-oV!s&e`P57dV>| z^uQ1$p)Zn9tQR^6CJ3fSC|Xd1{~4;opD&0pK+`zTIuEoqfMX$9IHDD(n+GbS!A!pq z+?2=w(?KmyLGqps=WqxQ)mCV;^9LA&YqVhO-J7##{Fg9Jk=F^;HFeCdr><4oI=GmK z0v7c@rD%FBnQqAXo)LcGtS^IMm)_5Nt#h9m>jpeti@=3`ges5A&(|GSpx?W?wXl-$DWHAiba%Ia`X6|W6xv2j=tu<9^Zu-y8D z$S6=*u#F5}B2v4KKbJA$|Dvi+mTvTlXQpobZIP~fcvn*|dcj4o*wDSMyDRp&W)lh{ zsvi0Ao`bW#fYPG%yY?wJ7srC-Dv|;pwS3Kl|FSQ?DD!(-hLW@*7?8J$%<$dLIGCSa zv00q@X?ATmp5$MObCQ#Za}8msP#jIy^^@Sf_~H^I!3plcL-0T#cz{4~Slky`Tm!)!65QPhvJl)McyMca_34Kx!zM1LT^pN0Bt>Ylhcn)~Af@c> zBiC)R{!6WV&o_bCk2e{+)tH5-FK@)44dq1YedLxbCeh>$4o+D$LjUFf;=3*Z8Y+r z8h5svf_AD@VUye(A0-zp`hEi82gRRQ6k714LignUT&G(n*Icb2Xxay z5&l4p*XQ>E1!BUh{pZC-=P>$4XCh$ZxP5+9fGGpk_bZ;R&#z5nqf`51GJBuj3TK~R z!H+(_yAK=6$-uOlAj*diXTslp!$fqpX3-oJ@>=m%141wMZZB+`Pu zA&K{9SZ_m89Z1+5FfnFGPDfz#I0~?|&T`_TuC%O+LwZ5k61Ob~StAE>s{+Dgyw|HF zzR3bo8Pz4a@w1^np(MFP9`b-GD=ilRLK;n?8zaEEUp~{xk~#f}*jM@!1K>Ou04JHs zg8n4q3(yBfe+Y0?B}^w#z_Gu85&8l_2!SB@A2uX)fpIh?H2?o!c&bgIwf$!k*K{t1 zUyhdUYCB6Uz{SDM*$1h}9q5|Cb6Mk0TjkMvG^(xAG1EmGfeX&roh0cjnxR(;eFlVXSTLbcT*if`=JlF<UT7$2?>xr-Zp3$TAh2n-(2!5vre^qUK3weR-YLL{isPvDjJJ)Y+;l$J z@1CoOweIluTfZhezcggr?7&#o&p3mW%2P=uMLMaLEMqFVig~;b=Cr3(rxf$aUOt?D zW&x32t#p@{LnFhsYza{R-1iGNsdqQlJ1gWQ);TNFMRCjBkM}<%`K)O#_wnu})9vre z`}XhcW)1z+K12N~FZ}#Wnyg2nd=4(zw)hC}r+3#POji%f>74ijyc}L%8Yu2GAX3N4 zwAz_-Xab0(w@Ud`{}pXUZ&inMy~MHlJN{4qVDHZtTulOV8yj7WW_D71S}ZaS^-=~h00#Qgz)2AC?)mKwn3aU-m-HZ>2@`ejdyk8NPayQEPv{)MJk@XC)ZzENv!n|T z9Ff69Q3u>`=LThg&`)yNsq094etTi}{Kf+u*aNl?SPNj!fZYJjr;F#u%ENegOr(MT z1uJf}CJ0>=NErnfR34D8BAV$J31H;N9}-D{bP$BbIzb1Y@a^LrUCCf;{SToAjJ=$T zoN-_u#3gXG<yG zZ0SQ^xa%frc}`+J%Abohn%Ee{yWtmVDOXOS;*3r*Vr}}q5{fo-B63p87MLip!N@$$ z@okX(wSDJ}ps1G0!I!w&_)A%Sl-^V(Fj{|w57d?(V(tCQ2-V$uCHva2Gn>R2B%F@lFhL(a)*|l`KW^qY45>Cc#HVLVFbNsB8sh(L|A%8&*+ZB_|y`pD8}z)HNK8jp2e zI0W-#XOd|bPXjr&%Z1OYvh*ba(DE&VF-8Qas!T3?S9Qoa8B~=n7p`l-*mv27Bydgz zRYj_Vdk{*6`6~38i>QX*{89JU3uQg=tTVf*Iz)Qh8w^V|8D!pY` zrH=qPrpkqra}#bL5-`t$m}S~^h!~7BRl?^r82fO5WQTww+f~AKO<4Ns7=U>}xW^}! zzBW$cjpIbD^H?BNT_zdvL?DhXV_yan@djrS=DFl|%=7;DFi&E@9e)If8%UChnRvqu zh~Pa8^YjNI`~+NTG4{CtPJF-0hy%&$(jcJl|10_>#LwDJ_fUCOkY&oJY30L8vmNY@ zyPNxZSC6M2vB+fC)YflLL21dQV|X*OHhpQOOYrnEsr2Gf$aHR{mwQ~v+ zDcomq5skUtt>3@OjoL|d_?Lg)w)wSfNVuwg&AxL*;xLE3(F0`B+XBNnOPQlVR4Uc9 z0&3B^Os~!9)vaj>RCwCL?>r(!S9uPTq3ypj_*`z< zsljYSL3y|Au2n`DqA~Pp##Z)FLBChV+sfc?tDZq*-eR%`WBs}yqJCY`sG)*;$@-Pf zrx~*IwbORw6HNn)`E;0}TQJ(AYe^b)CLPkIq~Th*L=d;0RtFz=RR^0z*yPeUJcx4aLk475u?>jRhzpos$P)F1+>pcjGM zv5G)?1N|$&&wIc<0q1#6M$#>}w(=u+9`kdVBJjn(9amF+)YUZTgCYs;PHE16y*-oY zhu*Guq#=X&T(R!-9FNv+FA>nHE2OV)WH$01*ax;FiEQ4uNN>QI;|71LJg}=O)hWoT zX*Dv?B1AsjT$)6f9X-wSyXExKy=7m$C#e30CZNa1Rr(2c()e6#VJ5uan@chf*1lKoj5y>r+T)P;;aqd!LW zfL|9VIxH@&OVO#H>dqm(JV}7%tID&r0bqsL68!LVrMF#b`z>qHAuZ?cbnBPj80jJQ zh(&qnLyo9JQG*m=f*Ou06O*>b4dUT}I0XaUqw9$yI%mZ;g{j`uH&#o0v3lcQVdu4*{&v~2=0{??(f-#PKJN}B}aglxWq`xn1;Em@RV*p3HF=_Eu>%&T}Bd$b{pDp$*npWer zm%hAjMV(@DT_oY`mGq+Q%HpOsXD*-Y~I8l zmr51Z{YtjCN0d#}Jv`{_K)&d6&HUdNJ|X!N_sEQ!t?{l~`(5{EjkK14pzeU6!GNI2fS`qdp!I;D{eYnJfc(4P zRDAT}QzchFjd>U~cwTAnFlq2GYw)mW@UYGdv}`qx{M1O(T*orAM8ggZ8B)O^iN+xr z!6CuLB~ig8iN+-v!Cn15QQd4>7z&TMyxbAw6XLK*?KaiqOV6gpRS1LQ@`-U+rmj!s zhZ3CK1fHAnCWeN?(8Gbxr8z9qCtGECWH~IUtyUOP8OeAXT~i2CUyLbZa0W0M@h?WpbQSp#>NGOGtzqrHJ+2#-Jk&)i< z?myNm2O2091~kFgg|WbJ+}qFiTn5ZSDa3*3TbgN;=7hZ?v`G|_WPBr6C8=HkIl*`4 zyuDoiX^$-HeB^a8QG$Vo=>RWBSRD(zQ21-qeKbt;8?2Jde;YE;_S#poAYY@bJ}ad61^jy8hexNW~R zcrP(Yx%fZ#RY&2G@}=xqy1P&2nS~0P2SgE&@?C+{nlUwb>xO|ac0d>$ym|+>@b#3! zYZD*n!UC7W<^8B#MpxVHT@>El~PLkX&}$I)4<7HySk(%GW+u}RnS#U?ow!_ydI z-=s9(3_etyFBt7hPpf3)K-RZtyj!IX|uDtDv^IZ^qzLl zH{i@Q^T}G;$c~yzx`fP(??2KLzF+B#bq%Ft9hqc7rA$uAccqv__+FKaQ~5yTcTIL_ zOAi;cZg!qyq{NVtlB-?*&nXy3Na@#e2a&QN)*9K#?;MmCh*>(aoyYTfm8Bl+pN^2j0$CC z6>d?aOy3e5*Roy?Fn1Ng`J}v$@BhWh1xoTR(0E-%t$7?;`QGq0S$?bDX}2Xy}6B$`jnDY zG0X;pI(aPGf4h-!==`>BRpHs^wpbkM_uTR5zbfj=ut)L&fNerHPl&GL~@SK?eWetVN;9MVBlMe zi!i*?((s(JNH}Aa!Z4!ZS98L4jA6-be`eOcux_CzcctA7U*&RsT=NA%R!d|>qtj5@ zar1{JE|*iZtIblC*MpxL#bYbHW>G+j;$l+$N(pL>}7P@VRtbUs7~5L{ZMU0;dr|=+WLRD2>z7 zNL=Q8%oVe}=<_t<2F@On&w7qL)b64o&*};}^erZwpEVVn=PY&B{(5A( zipCB*Oa8Xl6v&6`ZH7y*a`|?soS5@HGy2Js zBD^`TqORGrj?1OD>v$*zSNu=RTmEeY6T{IM1W_}jx6p+yn`>0niMEI0qH~vpR%KhV zw(F0o+UbbufK*e zq94K%7O|X8qt)V;bku^7h+YnLb8Crd9Bxs~T4@WoKsx$YJq zDT=Bam6_Wuwp?^B^B4l_4%92Xe!UIb4v3cDE2~+3DNAsjXTKudqu5w$W)N z`c#{}xOtksU+#6cZKm55*rZ)q8kE4d?|u@8_eiv?M)>SNlXasbcI^#`Z;65(FWeU> zeD74>`2I@Ly&W3gxMNGN^PRoRy0y9C^o^;;?A)2@@U5U4^$hFJxo8+@y>J`j{wLCu zE-v-;CAY7`uoxD~ZR^6?IYRQVd%o(Ld5vBq)3Mo_Z>_i-=akamcUuM9c$*Z}h>z9D zMfPPw`}|XHyZYz+Cycud?U1 z6vxYY;&3Yds8ef&N@Z@2(@bl|N=bkJ_cWVvoKw{+nyFZqg!k4ud+)RuAmdWqO0OD?D6{6`w@Sd*8khNu)pq% zx&KW*#bb6@_T#D!ng7}du0QhF`LR1-`!Nrz;uKL!~ zNRGI0h}7{vIQ09o2y#1C<=O7n#p8miVkBwP8&iYYAOG7CIh<6bp{ZAL;q$3fr_8v( zlG7(_(T!Tf7?gD|=TfOVuS9a-f=AiP@7z$ey;FH{c6D)mUST*m-UUf49DVsRc&6KZ zR92=}zut~?Rg|$-b;&*bnkHY=J@RDvmu*w(-{zBZeWSQ+tcZ?;1TF8P!e8IMen_L( zw=U_>N=Wo(z8)MJ8$4dB@-ehiX=;CbMnT6v5cUjzej*6Pr;;?=Dh{87_uA{nyy0~> z{2R8Ztj43lG3z^}n0U+rJSadZdArBiYYzoxSpHEL*J0{xBKG#LwOcdOme9tXpZ^ z=D)ck)Z_qVM|38x2%R`|ioY&>nxHsJ*t%dg%rpE^ou0cc5&ezFp<9@4lP9_aEcV4N z`g`wZY_ni7@VMD>1cbSt0vu+36wHfnF5J%oJ~SBEww?&9yUo^AlCmbx`SuOj5-D)*iQ-`1;?4TN1rpur6hoDP3;1B z|1)Rm2S=xYG0Y!9o8r+d(PBKbaL*T;ZqZWj?7l|TF!qar56!Q7!O^@g%~_+*!CE|) z=G3rrl;}+!wLW3eO^#@V=qMhpfm^J8H!vR$%a@~2&EOieeoL^#xxiY#C)OrbGzO1D z|1EF75?Il6WZ*AV0hzf^G$-%9xohuUcnurvT@J5*C`6#28C+ye9S6bee+Dy0i$>Rg z#mpZuH+8{||4u!6_Cjhz`l+L3dDVKG!J8hpy7PT|kr1(dZl2YiTipCZA25}vw)s&w zFU_l+5O&KLcFRQeofLME(w_6~uK;Jg=4~Lza0Jm(MZV3dCAAc|8U;_ha=o zV^_Chci}XAe`v;kh-JU>7~%F17d!EEU*>&SUiu>U&7}%fBiyNfB_uTB(8LRVFzZ|P z&@z?7aOsF7eCi{W7$Wr>bffRj2KVxanwJNB`_S)6ZBDCzmc1lD z6rf0wX7h^6$>c2%ui46!H1&)khitU8b#efTG-ucLKyk2@Sp%x5AsYj2oi%_WPnvB3V#(X9jK_wh1E{08{2Dlro2@KRQ;#Nk$i`G# zhoGP~9o&Gn(uUQ*%T^YrsTUJHbnsPMhrOWIn<8m;q`2Hc!J>hWtt>}V4=-luz)D+3 z8K5YWW@n1aD*;M?t*i=A0hFD#jx9h@CCx4um!ANX5L;Onpo$$jaMIR^0x0UF*{$Mo zE=7w55w@~%Km|~)+B#(bMUymp1jGU;F}AWbKowV7^sP;rGj8a>OIv5UpthAZY4*Cf zyi?IaK$5NOQd6%2p!~FTE&+-zY4*9e{8=$8Q*8h;bp5J$`UgYtwA+&AxAI?`qGf%o z#b}dwn%}tdHpRoTDRBH=&Um3UkMmvOXpmF%=cQBcT?&X5=2w<|jce`1*xm;wGnzt+ zT#73*6ge_4CD3L#rPimFa+?BL{RAZ&P`z=B#b$5$iT4C@PdZh6IQ(~>h!0i?do^zS zls5O@p@^zsU`#;7TvKB=zl*vW##v55BpZo!S!GRLB0OKltPvm5`%qv}iqe~^ zSSa>H`YE*6FFS8M(A!7op7}~l6n5Sg)^?H0%u_4Ln!M8ppMhDS3`T`qtZ|GehHhyJc2; z$SP4fDBaaZ4O`fH=gNeZbcGv=3Bi?)hHadie1!|GiPV*z#vQ}$jdT~5qZ8?sza|O+u{8mR39PlrxNmmG-3K^oKpi38ZvPx-(V7CgSys6?A!x>R!6oXl&i> z<-JTXZ!kn2bQPx2cDeU$f>mbrB(JMY@zZ^q&vlP@U?-tX)zp;-D|})ttKAIEW@wtS zxa3%&ppas+Dz^xhPH}-&%KwYhCdW2IqwZ;6cq>`~8E8yw)1|QYFySIIDy1-B@!yTW z!EX*q$6)tOxBu=nitmk^X9=zfG{TY#Hw8J+Z=hkTBnRO|{HqG%1)CyV^^6C7yjOQ$ zb?={lbYER+ab6+ScT@7m{uG{zxe&zc2HEg;eq){V%&Eh2tP08|J81vx{dB3js%Uf; z{oE;kynkvWPUlDBZB#tmW0lE~qp-QNNwpURtX>%j;8zh$pvW2>$KHfUbK|-lLnw<5 z(3VxQnD+T@v)by zIzMh6EE7zQx?&ykF9U9i_5$`P-U>c6&gUR8{FbiArMS9t4$pa^{zk8nNyp63hhyHWt>rAx z^CE%A?e}<4i)+>AgB60?!|vQ_vogdn^!!;u##Zj9X&^8gmbBIw&cUHQ zZ09)x7SD?pGU1{XLRJY6Ne-?H(HZC@)4!x-V6-g*wG?$uG#=pQ86{NYF8o#8)3{Fn zkxuLEHJgP^?3YAMv={|WSY&5SAdEXf#Ll0>vxqqRjFwpY9(=GCCvnhLT1sWc6<)z$ z(gY!W=7Tas9t!WrY&d@&3F4G|l7Q74E5oj01Yjdmn`k9Ve0k%Mh75;;oD4--YJUX$ z8Lm3!rT^e8aBrz&k=&asG2vc$kfX;KbKy$r>P`r~#XcoyB$pHx`~>dTB~mzg!>Tf) zO(fZBqMAx1s+=mCL|kZO!Zc#FtdT0RLu@+dGN^=;Be|qaPWUIPbY=rtBFg+reQcje&hSZoQ3V@7lRET60AI~^C+fIfndgO3&`XN{686SMerCbg8Tutmiy z-}M^pTj$~;r=6{$o)+)!o;!b^5F89fSe30Fs;xfj`bJ}2&vxW_59u-K`awF$`p$+} z5Opf!a$73B_}QcW?fQ(~y5g={PeHqEk2;0{rqt3YN%qeNEcK=;64If2Gu{>In%hM- zvj`${xh1tYc!KhM_%Hu= z)_M6z!Nk8m^Mn6ti+!+byblCh5x+urTV^dK_lf8x*o|aZw}4NiJ@r)^2=yhupn>ab zj+!;k%0{21aw_0g&^L-KnAqvwBp2_Kvp23m<)TYTU%RV3$sN_l5R2)izILBXCTgo3 zBQ}p*BQzId35P_fMXb=-$k~^5f?4p>O{KNu@atgj}y|YvFa&sEV0lCV)j`OE8;V!e?6BjgpWbSAj9@Lh$k@-aoa`MwF|NKL|@Y=32r29|jMh_n;9FC_O zsf?}=t{k^ak|GvMB6ZRm!R~HPuKOv1R7&YliQiAu$WUuh*Kq0hXS0KRa`SV^$H)2= zHppiCd&qQ$V;^UpEWAR?3u5pO-ZkM<%zc*m%AIc6X72Y%ug|Jv=TM~obIh=S^cY6>m)d*`ZSu~(_^(}~xG7mF{-6|LbE;nfw@n5XD8zy#?e;z+Fg7J;gy^K0m|TNQCw@mf zU0$;I@wcq}kkq7@)GC5s&oQgz@Dkq>y2M->H{sMk*H$IkJdXKKg@HRT3904`B&GZ} z5)+HmxJ|C%Dc{JlB|K$P(qX4)n~<`(rI4@{{*;j? zCLMM&NuI(xF@J2;m{di(rH&3i#JeYBn_V))Wz(J!Yv;H=G_TP@I^^y2?#pln8HMO$ zlwk|d?Q|+!np);v!#ic{V_ZTOX$Y_dWDlOydIKrnct;asHyCn&tJ>{``TKfGO=Y9) zu}SlKv6IRSum#Af(Y8L8YrHuyDW;Bnqs_5PZo@)C!d59n&eppBNan!u8e4$ahRlY= zvr~bOp+Ey7Hwya>ElcC6)X>UzQVvX~`eZC9VMyD>QJ>R9;!a|dF~Y8$5XUz2WsqY= zC7f#}K2K0)AqD>2#Shvcw|O9OpxGj`=|r&2yxY?#4t$4~kH@{_F*D(_S{u$WbCo;S zeVLSHQ(KM!9#dXf zhBUFKSAoU|%B;IfV=A1=sN#-@3Ar)}jeKaT2+?Om;bAeA<96e=#Kz7Ds3L|Uff`H$poo%4 zlZ(rn0g8o<{VkvZC{>Nu!kT)M5kuHO4d$C)`;sJy6sW;K@)q#dZ0zqf^)w@guz?yZ z6QD?wNP!wm1EAR0*gpd*fC6f;c7P&FA_Z!&c!1(yW48iSQA5~34YmwW1&j0>QslStQr zSc(>KVK(+ZfGTzf+XWaGK+zd(1UmyLx+GGd1iMwtnoJ#A8{ut!k4gEWxXx(bZG<8y zDl+#?fT()zyo6Lne{ayZQvzK&YH`$lr-Kz)E*HD8kAIe{P?A48_q^ zQMIR0D06J}Cp?YBj?oWVGn&H*C~=B&VtbYBt$1=B+y~%P6ISn5ys7Hpptp0%dG;WxQTFK>}L={0A*W~W}s)g@g@v~?Ua32JDZ(&dt4d_ocH;nvN za<?|r#S(yyevp>focbRnMe**#y4g!Bt6YC09M*-rnm}&| z6&F_kI>rGhC)Ze@0>;58yx9t$lD7DUYh#1f$~X7fDe@Ufm$iQ*G1LnunZT5OTA(E- zkd{S7?7JJPyoe#3F%vbUet}0cL&V&lyXfB2MVHQ8{L8=Z5T9RFV|b)z6JwNk<|1&f zZbDAF!jtfcF#Sz&QrNngf>mj*j$8jF)+p)B0S2Uoj9*W~_E!N?x(=>RSM0Rktfq2X3E-FL zqm1Dq*v%bkn<*Y^Y+alro{uqz%cP%u8 zjl*(Zgothl?vV56Ye=OA+@a#VnOVOAsslkf)eaOXMtx@!M1A$JdS3I3Od6)T2oVAwreb08{uMB5%ROWwpTr(|+ z@qD?HA);#-wsuyYd0gVblBhA=~wO9TPiHgr<2)$Qghj`DXIb^)*o{iUW0K-m%-{ zk_t>jQd-I5)gv2c1y5HEfFdbo?H597RsImV+CWy_e0+}&QQQ=s6yL#s?A{G-I0)%N z#^XCSzU2%=J-}Euo-<1}0y|DN@($YIy>DtZzK^a&4GhJQajw`#x8F-y|i z*W4ayOB9(QurnifKY7%OoaH4}bi`;4*83KHTsjd=s!ZL}R`3}sIZD4)+qQQX0UkDd zstq}R%eoyGtCrM@r*=BZwxhl6o~+dnFswGq>grl*`ZQ9KTvV0#G;ybfU$p8{s^yrP zXfCx7A(rbxWJ3cXntqv;*~sZpsyn$ls3$g6@_g{1hG$QdX(+0nNy+K3vvu$EE78YO zm34N6SBJgoe=5`2Y@E~cSShff39lOyq*+{C^mZqxKnf8xgEX$VgRY%bB~#voIC8Sd(8q+%Kt~t9-as)#%(c#}(3|Ra zYcr|pbd6s1onF~=2u{(fnI=Oo1>_9ErcA%X5Nd$Vky>c~<3km@Ri00;e z`W54U9nO9)!uoKyoBXUN#r>@M^oVkIg@esG!F{Clsmtn+>}jJt2B9pPGi4yTS7~y8 zb92AFE{k9%Ydg?g^;7O@)wF&}*DJfC^Cd1v@_2Unulz{9N2G8c3h5kHtnnYj`d{v^ z-*axGKIuTx{g)W%o?;oJ5Mt(~dmi>CRcjSkv#0y5id{FomiK-)>IVe%u`_gr&YI9Y zW1*+-{U&=-X~Y#$w3KC15CLPT`ycx!v>eB)6nEdeuA>E2=&vRDr&+h}_qGJF2afgD zKatC|vPk&&i+k>j@<}MdLGNk4X=l%I?tE%3oo&CXj~n-xj93e@e<(iEOD-LJr58AiBKz_n@clO2~oM{(C7pyGi6qVdgVklpYkh>i5*8 zdVfea)?x;72%)%uIS4kZahf}|iO$A1-8-Vk@oJ*0UO<9VxcYqTuW%aWc)<{jkm$RZe*@C3}*z%~xhC5@%cyzZ(p zt1}ay6_Aru4~V2@9Mt}^kwLBze8A6TSuExgxt`?s%s+z@AvBY75wkwazJiNa_6tlI z<6vp!>m8S8-SVGoYV+#Ekp`)36k`+~r}+m;<0Z)f&?JIJc4^Ky)LnU%*EN09e9!k% zLUw+lHq@bTJM=`i4D79K;BerUqykGyP7ZwD@~q!h*dwd&ierG3+I zM5ZlxV&x0C*&&7DWQ{EJR&X9KI3*JOd^aoj)Nd4U!hsU*ze$9SFs$ImRQF>im{^Br(eK>lSo ze~MM_l{*10pAH9qJHp4PS8P;@FLjN|GWAZDu=mJ21J7mE!QAsEiVpAcv50C%z1YU{ zw1>C1=Yv9TzRAq~s`Af*CU)@S@((PmF($<@kLKd$g z!EC{mg30J6Oa6;g$qLA7lj^BT+7X%H;B4l%!r_dm@&)rjOI28o=5sc~-sa6WKu_rB5X5 zQmkCXR$Vq;MSW<@pweaE!icEJ$SXKT3Mb~(Q}bICBpLhmNoecHgSv* z=koeyMf$YzY`wWw{~|SDz4?q4HTs13E;9Ry@*3rKn%0$=Q%Tl%iCS$#f<9uhHs)ix zk%D9loVT^V1lZW7C=nzt^nWt0gN}d9feYT-j(GOe_p8X4{!C#EnfmZuAL~>7((P;g zU$=>wZf`9eLt#GPtD|Oa`WHH_YNwb_!;rEekwNW zm1yfn5jTm=>Q`CCC6Fj(=#oUxY~-i+$p~=2p{x}a;MbdMg)99evW_UEoZmmMW(=JB ztZGt>kOKclBm;Vn8A#878Vm!0h-5SXz`%eS1_RL($;1PIkpVRd2I3)-nE=2m2Glqh z=pCR@!VF|uXCAV_0c!vv05LP5rocd701*IK7*I1{ASXZs09FRn92h775PiT5e9eGb z00YGX8UVauKrMoSasdqh*cedDV4!M7vk3sOGoV(%K)(TvGG-tL18N-%Gy!M;z{!Bx z1Oxp8Gyvc-C3LoXiQWzaT@lIrQ^xG(W*1sdO!mJF$UB# z7^n}>sAG1EGoW63q^p9Whm*&qkN-w+RFxD3s>MP^02zr#oZXdXPE|Ab)R7fA)`SUL5@r!dY78y*b zCJhN97CFo=I~fEx70mE>Pj-i)5)WEU|A5F(ea+Br6dX!n+5V^F~MA^OfczT!p$21Z-}V$gS_{PY4H=^!dAluJ9$K8Kl6k>SEl}C z;3&`QqGGYd^ppHK{({9GGpea@9FN5jQ+c(Z3xx%OsXU&S$C5h2z(Fp9h)$hh;DCmB z;wFm0y!B7Xc?M-aw@}}yt6YP0B>GONJSQd_$|^d9 z_{Kv}-S3Ciyb$?R;go}MoMn$dKDprKCl!`eXDt3gi74LjQv4E>6 zug}R@S}|GsrZirwcoM2z?~Jei$bR(Tv1jrbKn1T~x&PedAyl}XJKe(IVNkI0k2?_M zH^&RZRpRTBcZ{d)-J-}#f2T!kW%E*1HMazd zUm&oMWnu<3T_1Ug>%p4^b@mwl1`LrJ(4wcV;B}2d zq_Y#&pI-NYrSZngM;!KwCXV*4N(0ya@r&ok-eh>xY7kbc9psq(Gc z1+fM?^{du;3;*a=*n%~iS}!zL(rpA>ZT)YjYjuPj^?YiIPkNVX9ad_DSMJrv_XW!x zDweD$Oc(g!>P=ock=w6$mus2VUk%Uw(NDJLY};>qu;vtd8EW88_P9`6=0rYR*+$Mm zw?^jL#=SOu+%l)v^aPD{ThE13)=6tY z74;0wq4S{gQEAb(T+N3a;>*@_^=wXCNkl(#q(}&7=c_sSF~z0s&X3#dojNRd{Wm1m zM0V7Zu1BZ(r{R!fofUZPHYYii6pzVcPie=Dch<>NS7q{W!|I&T^P$no?92R<0|F1% zc?Ys4L#ed#HpmwD@UB5ulW$F4>X_*6kL_U?S#lLR{;c0hW%_mozUA=fe~!Zy_lIt4 z$)%+aV@1K+ZP1H-2Pts=!}CF0E1g6A{L0;J^vM7x)WBcATF>KV)St|25WLb+oSd99 zfS7u^&^KAB>Y&{BXU|!?@ClA=m4dY;OAWUiKG$bAJR(k;TDu7BY924M%C?`22{;L2 zkI9VS1)@!*My@B{ZC$-2OeVcooPG1va(_0h#*HdHpnY zuapU$N}XxLyC!;e5pj50i~%T7TidL^ohFPV*MB_cbbLizP>ND2pk3rFH(WThML zyus|SA@qiQN0(LTtpxrs7foaYRwmuOs_yr#a1 zYMjsfT2V7EZ$FcII@&>XG;zHT8KO2ik+4^J!rSRgx8L!%7}@b(8|Ax!#(#;Gkn9ov zr+{(!TVQ1rq8Mg{k#>-=soOA*e_}Gmp@u()Z*8ESG11g4IN4S1ZuQq>(KH10|MB*f zZEZJCxVRQcu@)z|w-nmq4uRrs1xj&uhhQzCc(LLR#oZ-P+>5&ucX!MAJ^%CKe1r4i z-kIH5zpf;ko!ObA(*@~~uC0n%IrQf@%=wh#QMY?PyfzCo7gPsYMsimLO{r`C`CZI` z*oQY(s4c$JA_k&_iZDfo?h$+Y$B#Lw54lmT(a-0TR6#0YHsNwhXxX)+(!mvL>RSi5 zbB%tO*iQhmcGq637>+9N=@KxWXZ+4g_OyFi2{MSUL=n34;JZ+j3!$ zd>Et<1}TC;N??#O7^DIQse(ajV30Z(q#;DC2?l8i5o?1%;4nxh4AKRI^uQo}FvtK5 zG6aK+z#wBV$T$o#34=_-AhR&YJPfiJBDM^JtimAwV2}+MWD5q_fkF0QkOLUx2nIQU zLC#>13mD`I2DyPj?n1;KV34N}F$8QEg>W2?HiunI$|C^Ag$!Co0XJhvI1<2i!Gz9^bNCVrI!0LP6vZrH8~;rIy9LR`ut z7{-MOTE+)AQ%kvu!ghmzF+f4=5>g%^FfMG+@=I_tla#A6Y&QoOgD$585PXAi;ewV) z!OdJ!u12ukKZN6vbUEzwXdd5TTzH^ma&Yr|DOU&B?)u-|zaz0nK_!w>9-%O<=-L;J z!V->tu-#{@PINUw0((NxXF71Rq?Buz6ljeUG|mUk@sW}u3xnCe1bt=%H-D0HO@{5h z2aUUda~>GbfJl4x#Guct;AV9x*CN=i7HGT@ zZK2AYIF@Tkybgq^1rctdf#q=|Vn|>toP?@|v^m{kQptb-6|{u`mdBThr-HEv6RP^t z=Aem7y$*t@p@Fur!16EuoZQ~$@QO=$1;JjTgUoQi@+4C6?8I*aO~s|=gJ2Wrpechu@S_k@pIo0@bj&> z*yR2tpYt7=;h;84$z5iG3prNPTTIvNEq=})m!HbWY(z^`r3<&6rwm5JQ-++JM&i61 zTbe|kX%*M@6YILVChw#pcXIJ>WTAI^_%LKE>V$$c;E7El+X~LvPwxz+DbEk+uP3Yh zQu4m=(_>lLOmT4~#~6zMpZW&d9g$aF?ji?n>F)&f&7IAejj|8>4`~C7UuTqBc*e{n zyne;tDaGK$w~=FRFclG=;-vbHYwW|u`cGLp>T`3mx?Xy2O5M-%d^e4IS zM^{WoreEMa;tvz7wH=!h%QUwQiqjlLJ6YC&=<9G8rc@^4 zh=Jx|7CjD0@M7ZJ(dQKG)7Knk}*WvT|29UtL~_PzKN7 zZoH~>HRJ5bI#y)CD080dc)QC?O&MI?IP!M4JB~6~rs6}%+0MHt(RU+I3%*`o3`~|k zcWLYHIx7w7^fm9XyL@~o@-J5BoO1Eynk(TC{9ac3lR$T(nt5EN!u$?&qx zSP`g@gVk9KISeub1-ZrUqT|w!<7#cwdUq~R0`<~^chL`A2-}Rm;RD@{i(dw5vPpC6 z6#~WVZiH>@_JThQEA=peo=bjw);!#{uX@O-&L(TsnxrwR9guJNT}t(v>!9uN02;ZT zi^%r3}o^gvxuKrgkya;@6yu{P|&++y`5-eB#C& zA;xbsD9~iO>ckhZ#yx+($9e&?4(fpYX2NdxQa=f3>P-9Su+XhoSWa2kea41I`LEIjtBM#(iXSOw5umEG zFw~j@3!(aF`VKEdg^C-ZB5evWII#n&zo@qPR9;qNCoQ)TmSOTEuJ>l}D~Fz!8xW0S zmV$KFAe`gbqZ5^}ojXSDTrci>d!cz@{KD7RPZg=GNxHm-T!QqfTptAVW_sM>hu|v; z<%HC1+NIjkGqWtL6!Ja;-+RJh6QJyLT1~nJ#oqs-sup4?diV9=N!F!}Ow(yD^2K~k z6&gOf7z5niVVkQP42`>*YZ&o~;#ht?z- z0v;Y;8SJ#Iy(bGCTH=sy6(%*hyQ|j6}et?f4M)^_mR7)r3+QhZo z_*OwMdkw^ouO#2!toPGXdn(%%l#sKBnuZ0haa@7CkxAl*-~#57h8?TF=Bvg+iJu>v zcpCHd)_w|gQdxD9Jkh?Ge15^zFGpbb}$aj!Geqxu}t+A-7|eI z&1rbUG}&RAgQ7Lg`|B4=pys6x0NXH4-lusOSVuZKQAgSXz)d{REztD`fF_xs-5ZlA zQhX$gto(6Jq9eVUvjbr*&GF*LDEbCW2aWm17ZOF(#4u|e>B;YeQvCm$uqr{x?)1x& z-Lr_*vrYFru+}&#qt^HcgVs0*C>22|*fpK$1{aW{% z8h1-rlZvaE%RhX^VQFxRwI2%NeTfYzx7>0B1*e+O00XJsl;Z|^)ZwoG=t8V6iPA#T zCx2Oru{^ax7we`d=bsIq3fSoeb*vhWmXf~abKBG5H&Zp>fcAp5WnQeul}6Cksoe1X z#Vw0R>iH{={r3gHO#fGc=cb6dcst63Ua;`-0M(M^d2G>Ks@rhczXasgR{Cm8T+=rz(}FCY7gd=w)5Jl~4#D*&mE+ zKF=!`+A988)mr|F*N2TKI9kvWJwqQ_!(Zv|`u9kNpU6`o-J?L^jX=g%YgI^kSivK8 zN_1koVOb4$&V31~i_wGIetsQ(9sFRv3VZ4+M}*w-mm$zXf1erQ_d+x31?S;Mt&TEo zpIMwxQ58JSkinMiUFZ_LXkVhPVA^?d1f&kEC zx*O|rcv@*2Rlg-|OXDu@Y)a3YetyMSe89@>LIAm^$0Qf4&c&8$x*8JXJi}Fg1 zAa+QfL|HWvN$JSLF?FF-S!1NlNEQoo=1Z z26M^%bN=F|De;At&DL^lwtED;JTqtNsw!^GR+@6%?*r^oNC)lU-f!wTF!79cWWkaz z4~TINX$ZcGvB$(!v!@G7l){>kYNq6npWZ6w(suJYp4sxEydC7Txb$pSuWj~XYopd? zbCMNak(%B>fSy+A*todfi=@6s@-2NPz;gq>VYBuTzQ(o+SFF-m$KSXai%Ej=-^ll6Kc$5Kk@2b3(L?2wr-(82 ze@n79KH5n_I>*7&qQ}&}dDj%CDqnOv1mO+({wEeL0a|!3(fZ`T*Ir;0&3mkGhenEa zINY04#2KPT4|Zj7jDjrbDQzx1WnXyQ8078~f(Z>%~+$5*y-CcZ59UJ6}>_kGHY|NVaLSXmd}PW%sk9bY2j-j zAj$D>0Me4tRC|5hc9>u7uw+UqX!?vnC&B zY55SDx%Wo=(SE;3|6=h~8=c3kA&+FyhkShXwatJjJ6JIkqe6>5*fmhd}kO71)_Bpe#PHzj9V zS)EbLQ#Hl4-C+rsB>HJ=B;t!*Th{h0$VT`6ZHs^J#t!1PfdoH52mwOYYPrux4 z$7wUPXtVm#11%(65SNf)i+h*F1$(-luW$Qi z&xCmXyzio2)(D18wd;0%Av+(w^7IF89R%%mwp2U_gq{~%rA$G^$;f~v_kDJXr!jGH z7^YUX0skA^t--+0l)VU~A))D88sn_P*9dj-O+eKY+lOcj6M8Ou??*Ks-iHVY7kb|L zNdM+_G(wmC{FWwncmoUxLf{6LzHj|ns8ezqGaZBR? zbagubR4xCnY8d_oGyt!rb_VH%92Q5i|04kjDbm-U2S_t{1VIAVvXNs`w})G>v87B` zb;t)iQYx=zFK??>9vw6!j12ui1x=>v&@k6up%FL zU9n}m&Q0mD3EW|XY?c8BU%1l?Kcxy+^@zlCz$b7SZ2^Du^V;{dG}f?@QmV$3%<#ge zx51=AOhv%6h|rNg3}wmn7k^q+4K%K=F$z}+fp}Aw0y??X+H+RHA1jqsXpcD}(WTiL z(LVW^61>$ZuMX8YQdDtkD?TuSMQ+-Lw%{JoC(V9qi1)e9U8?3e99fKSKUDWR{y|QPUjMZRbIw&cM=Y`ZI_AigVQ&>O1!@hSqIL9(zXGLtAwzQtEnjJoAPW$alcINc*a{D4K;;6jT2>cT}ZyH4q5-=3K)I!&c=`Z?p z)-R6h_@mnG7Yt)>o$0tA`jH?_>es4^{E_NMzB5CSC^T6l5?Q1;0Re^|kpvWAypqU- zDtMe|$jg5v2vzU_=IB3$0AQZ}V+gs?Uc0ptx@z=2h)o%z$>*TcW-&@+G3jBUc&~rU z`bfo%mOgnVp<)9tk(H+sF*L%m3-^da)WF*9N&>XMsa+z3VlMa(Vo1xv&NNtAqM?4Y z0T?ya1ZZmXbbskdhLNS!vaosO0-018(Nn~dh%Hgf%xh`TCYaF^#j+`NwCYX)BXrDo zvBD6sqQPzVJS~YVZ5)n36YM~9P^>j}pgkzo85HXdiuDG?`h#MFK(XIJu|GhuQJ~nL zpx8uEYzing0~DJBiY=HH)5O!57UPpeon{s?Q2)Q%Cwx*iK_l|`&@Ner;j!dn(K;1h z)u-9GWC5C9Scl6^Rk-VU7YzW`=P-1 z-i77>`-uGb!H|NBnVbEK@H;`kn!ev;gG{g$EPQgwyUl)9@^*C(FX(24QR10|Dj(&Y zHw2yeyZi$3P2IN~*@171WeMTD6b_fzLc~SoXRW+X4DljpwGPpt8mUCd-6mHPH@{fE zrbPHCle2zmoBWtGaDApP%hE8CW-eH$V__K#IR5BGCIXicm`h<5guu3?7AO=3zc^WN zk~L&|#cpE|(c9sg%U~7AT;puZNb49ohZwtSM4%V+_2^JBo(mZV8AOoC!wUL)baE}L zFcg21+I4Vvjc=>uHCyIdf6>Iyl6%plQUnJf&my9;BD~SlO>^j{hldR( z?&w~kO!OOWqLST2j|ZuTy*BS{g`036?g8)PvW(t~*b+}8SaYbC5$q>Bm32q?v(BRL zk(S4iHlR;7YIvHz9SJ#D9}x?_n)%DU<$!DV!GHE&+UaterYh9Rj>Wrb*q6o8uQTFY=c}epPnK5E&2ROPd%^B$I@xa?vQHIo}y? z$EOfh^KD&7w28-v%?2Z6@~hzh`wp)9VSNp z34#FoRQn3bHIA@Rzk=?vC0`<#nYV5lj{dD6dbFkXpJ-b24X(X^Hwv}>@D$?Gai4gm zZi`?&iBe-v%Gx9SzKNh|6RUZieBpDEyU&5Gn)E!vr;BNU?gm;JM2#n?X5Gmn2K z_BBgtcHOGW4?ky>L;N0Bb+%9@sr%8DMG5O9{dy!0;DophaPDG1CT>4P(tBbNH+%Kr z&YTkFl^CwNmNzyO+NDIq6xvg(+KCu{fA}yF=CBHOlNe7BYX|5G@~#GZ{g))G3n3RM zouT~_{1EF>^YnECI}5q|wKazTZ!9XKiV$+`8lf_hu%po1==Lby>GI2{3#`ioDhaxxIYc{oWDhSyv9n zK^`WLwGJ+0*`LMgC+<&YN>2u7N8fJBxky^;k9?|mp5&@G(opJwiX)3FU91L4N>1N$ zV{X7v>II$Bj#~e$e`Xr_8FpgHQDDwAG=QCQwYu_feA{ce5w`L+_I3HyufiYv&6tpG zYPz+9ld|MqO`6rZ;aW7Im9dzK798M8|FEOiMQLJG--g77HV5l{v*!hU*(zS}t>s-bHrAQ|v(MFXUTE z`uCofMja*`1rdTFmI@)pDpPhSGSVQDSEQODflk8X@_4VPq_GgwoZ=n3*qx`DY-&hq zD)eZ45}pMhlsekR@otF62RxeOW6=7bk5A(^R4X-vxUz;>s2LRMjFm+Wov!kc$?yD6bWBuZX5I|k?lEI7^9xFX?_ggks*9RLSi?A(Iyl>G@rXqlf3~29oKRh$11Z2`C0*&q5}xO_>;peZ1405 zh2TD~?WZ`?Y#(b}NgXtL1HPd~z2!oh%;Qp=r#-!N9G;En3T5nhu`leYBLUy3Km<#o zJWlK(2;s(V&&u{ih&d}k?)Ogf_rkbCqifV_C))a}MrRq8tEeVnSFkdIwSsRqND}}(UJ!Z`h|vJdWgNil@K*}b_>Z_4 zb1MRDAAFs^@>7ApXyB0pz~Na7jsyy?%qi~>H@sNMt2;q>_?t^|D*YKVItu)KjC{MA z{2A?8c!RVmqVUC5*Kb(LT*jBGp*XU>MjEI7azVF>rcGr$)!kU5zzWiWli>=_Vaq)4 z{qM3kP;S#IS|&-R&QtXH5Iw>GRZ2ZjfNo>Yo$%Y-C+-+4O8uAidXzV)5z_+4m~$P6 zjWla|fqfm#ry>Czkf;ab-pSe6bk7&Id25X{#sNT*75!h5xd~)vdON@+{1JiZ?k=xT zaXEJ0V35uo$9^>qe4^~_K*2RPd%?l;HSv$K@zCW1k{T}N8{p6iYTfP63fcD$*jI}t z$AAlM=x8e6#b@w*;FJRH+_07&=|lISK~F)b>DN#KtsT)V)5pR?gdx39__M>Jf5802 z!x06Gx96U;;+aX#wsp+zU1;?Dfr>xtYX0iT%BK_gy9T58WS$qL8ji<(=5|x0MSoAF zxBo=0B>V_*o4wa~fT(OVB)M=$MJ%>Y!q3+|{MZX2ezYtFkT9Py*&WLUk7iCRqhDMO zw8ZyaC`Gj3DngVdT5u;Vcz_l>P79t_`HdPPnT;QV|4l$lhk(Wzd=*~G&%T1-LJyeT*mR<`SWSer{)~A0j@fR6 zedzso6VdRVB2#lY>x+|#X+Bd$LEd#Z1^k^>n`58OQGas(zJ6RJpO$S@DZfQr)lBz% zY+dLm&%HE3_~l7eCwa^pHtEQ|3{}o-{d;e_^0a}hUw?;2=m>q>%c89~W7^?AU6tfT ziw*gI8S>X>{HKdK%Rf)9zLtGpzlLUG+mFZpqxC6`@2r0PEqWv?>kSd^)WWf+&yc%4 zJF}3|LvJqMguqK3hBAsk&GY+lutcqro+t|L)>SS+X{AX3eY_tqf&h+`csxo^8 zOX8)!DbW=WQ@m&iGwuN@4!0li9wQ;*GoF>uwij=f=WzG!9DWnK)|$G zu`%LIfdNx^KnCCYy{z-!nt^`tu|o*HxA|-S-Pfc8;$s->n8LYgQ(3Zj-p<;|xh-Ek zH}(@}MgaYPPK71GVgQ36{)ffvLCP94`f^}M@G`IHVD!|n`VudqNlIufdcNk zt}*bITOav=!GpHy_JXKKvJHX_X7I4XWN)LZzhd~1*ho)nu%vt1*m&`e%V~DGZoMxl z;{13>c1yU=quw2zpWTk4 zTZaVdz=;KxWV~U$#_`;=I9tYPsE?HCm$w&IXWSCYq z(p=~71`RenG`4B&LWjl4PlAJLd&L)cy`wIB#r4vL#q|i1gKKAa$nKiLDf65hUQbYy z-IWnhU3+8VFrbm=wXFo=)DYJq3m0(T=Mo^PFqQ?(?@*TX`0wNVB7f0N*=fAOOf?jf zI1n5&o(;Wiq7I5lDv%8D$V=cRqmIm6^&h1`zF>DWj1V$m-QxfLJ^DOmlnb2m=18grK4SUlqiioky!(UHSvpWx1Qhzn)JO+|AXK4^JcyZDbYi zJ-L&$hZjxkdB5(Rinb+%kyvn?xK8^>=RHIu(D}4#ziuAW`Fyt)!6yvQI1B7Me@*i^ zHR5yB5NBRm@|bYb{PoGa6j4=i^L?7r!dAiGp|^l_+QN3lz`a-21owoqX2HuLvUIzR z(`h2&$bV%;P~h-Egr4 z$I6Djh+w%Ib4oow=iclCVMEa+v)`VkOCT6RAWSwA+TSLJ2Zp__TRni+ zjhT`XQ2QQQv76s0iJsoxQPC!AXVo~R28$?q9oJ(M<$fbD){CP_*RHvZHR|h|Ir1Xf zp84YGkZEuEGO1iJPvD;%>3wVc{OFJgET+(L@#D&1^$Zo4MC+x1gIUvL>OK;8_<7ySEM@$drw3I8Sdz;U-B4Kx=j375O1)P+gH z{Vyr!0g?#h!X)AQA^l2}_jOq(l8NhoE6C}pNp3v6E}AJTNkjcbL0})J-6V;z*q)D< zEs`+X-fSZtVOgz>zYaun)fA!rHCVA*Sb=*V(J1BVsClSE1Jbc#?||X2bKL}tI||uW zYQK3~izbn;7tyVJYUHyZCCD&2GW#U5tt{Ghe@C@>i{>0;?^^C8gc;gju3`gqHKf{< z^{1kFyXF(GK0L?1UPD&vJVA=6lZ&88vlHE@dVq)IEg~3^z#YcoK#X!r1C7VOb0T#{ z;Xh7DKzv{(!$?H+&b>#JJS4)KK6D29B$+k~oxUG4sCjN#a5p?`?G0AlW|bj zsQYNa=UX>-98A%@v3=TD_26++FW&$+VD*E#m%H{^lsoQhaQ=z5(Z&+x^we(j$xfw> z_LXdGsLMEYX(<20?J|f1Z$B6P*YLIi0)I#ZeT+I`A^CNTH^{3ofnNqD`N73Xa=ga) zkLgS#&?7I0!~xRtgW*%p=bKMG(0PW0Tkj%`^GA+>n^Ux+sf@_1H=J2q7D3gN1jRfc zVl&>~15pxpB}t}yiDPax`Z}qnGwWQ@D!j?QbYmB~#apaN$$VBC7aQBo_8%Jf(ihto zbd$Z6I5l3Tr3}~9%D42o9}h9+vl73|33T~wxa7ou`${l$e{uMAq^bBup$ZjNfP@n* zG7euL7GUU%!cT}IuF{XqAWp|jh<=5OqB|%1Lj_J7gK>jv&nR`Sa*YcPBSeX%1t$}t zWYdEGJD1Rcn+Q?<-?@Yq{ErsAM+-iq1>ezvk?FwLbYKww_hBkV{N%Q#L26i-b% znr;t_K^!T0JWSDY)FmzWT(u;3WKW*=w~q@34*sQJx^zcdvyN@zmXBGyjTa^uBN(yW z(LNP5P}S!+Qf(r$EfDFqnh0C?bPMi*{;LXW!|bz|9P?CoB>SoQLl z!;ke$nb*R%GK?@>gfJ7Q-evCXb0JZ8&&JDcMw^DAL_@0X{7E=j5??czE1HI*{P!+r zg>8+3;eo%l!Nb8~blHWX(dt9DTbp(}p{QFCr@yx{bH;6PgRir`;N52W){MFu`$5sc z(B|X`#y}xK0}m53M-3CS+DPbK=`UaN&!RT+Enze937>jusGj4;MlG#`$^OoI+fSgK z`Oeaxe@k5440jvb=h76;dnnx`T{asdX&!>Mm9#kfR3G1$mp4<;DAxM6+y2@L*Y)F^ zIZNj>Vydn5oo*@LI&h!nSmot1YkIwA*QEIzxV5OJ>9)FcF%3MV6_jo|DFdD3Rt<}k zzrjDJdNOiU8hS7@5em0Ur<23I$fkwFnx#AlNtwuS2ab8pN9XuH|3yFpc3=yu8`7g9 z7Nt@V>Y9_pnzK9|QYH~fp*kC~XSySxP2cpO7Vpme7PG^j?+7Ej%TUK*e-(JcpZ_MA z@Qw!LhXlSymbyfT?Gg}8j}KGyJ&kX_}EW^YpopK$UyIvP|4Q66ytrtd%I1;7CMS-7}VrX-es zC~j+slRWC9@{HQ+`f2FtC*}HkIIz*rC9E&_zQwj^Av7b;JR|?1ukOz3j$jQMUZ z+@JJz-Cst_!{jJa=noLcfl61af%}iMX%UD7dd5QF+D|lmUi8~cCLgK2LnOz`SneL? zH$AttHkBlQQUkdO&_xZ=eLdw)R}I%%g7XJA>h;(W+h(QbBck(rmJxXNOZnwgLJ+n5$k-b36=W9gl1#T zcPtI(*5fNWo0=E=5K-EDRO4|?l#^e>rfqThyZ90PvySW7?=qg;W8%f|`e~`O%4=H! z!32RZrJ-9Pp{F-gm|7%LpOIZMpB+d@$S7ziR=Mfe1TZh5zoGX}h{s?Q&8N)Zd!zXg zz7_Z5RNbQ;r!l(i2yZs^XLrz8yWqH|{la~}%PgDOYQ`FwNOY;-tzr&e^ace^ACzzSG#n+3{eSgWY=>{ ziqsBfg|9S@jWb#6nSGI0PtDpl?qJLnl$D#Wr>!SJv!BtZJ!**0u60gpYx3Wp$(&Yp z<<+OlemYtg_;e)cBTx0`K%Of6L%hH=3!~e8ZqQV|CDmz$z1r-mZ>P=5&t{`Wf|Usp zp{<=YMu(tKA*7YFSDw8GY9|;p0kTWX(n+cK8Gpf7)1`^`39q@Y9_)*!5aPAOKE7GJ zYclVw)>5!ok1c6Vo$K;%HNUJaJZj6(by0Q8Eltx{OB(FPi1d6VN4L}?k;X# zAB^Hj$5zpOA%OQ8vRmWv2lxkPCCz2EQ)I?@9p?knVgynN}U4W#rEup7HaXVAKr2I%x2LXzuC{+Qi$KWpjb2~O#y z2hd^+pxi*~hBqj)l@m~H<9y|=)i4j8y=W4hy|F}{J@qw7p>BC-ZMQtU20$Tzasb%? zi0K`P*`EYZ*!@0)CA3#`$RkLFWmin{PuMCa5C+EiP<5a;8t!!-t@~ME0yJ2qmp8!V z3W4D`U$Q?<12~K5a4Wsc1*HE?6) zCt%T^3P@3=`6qN$6L8wf2{>of#41HXP4dY|92pr|=?Y3${5&T2bYj=Caijyr>n`Cq*EwvH6vV!jOO*_o>%L>S175Xs`J@M{JD)CMcfSGjS9Y(BKJ8kvw4WaIKQljqmYQYYKisM50 z2ggMj&(Px)c<50Qpvq>6cfilTmd>^^PzlZm48m1COaly*$xuD)z+N+a!dyKZO{Tlu zK&-nh3$!i?=eR%x#<+VO7^SnVoe#yd3N!N;Nj7^ni#2$8#+u^0|b~G@|7(n4bw={!TwY@y>xPBgZD1aycfdGPsdEl-6Jfc$O z$tP^p!`#4%xn*I*z^SSUvvxN~6;R3U+6^X(oYOpm-d>JO*Mq=I0@|K1D_GeTD zYH;GeS=D)qNa^(HHZos_5wTly&AA<%r&TLQ19<0r+!GYA$$Ei}=TwZq*4jbroWSHRj3Dy`W;4JYoCJJCqrq*Z(J=`e+U0< zM-_6lCR)j$78FXh9XnN5s-_UxKHOVRJDqX@KBTnY}Gji zY`Im^3?0Y;%{?5U~)xO>s<{bT!lnH~SEsgJ&XpUo!{n14Q{W&x}5aZ7E#e#@Ef+>1!~$ClfaP z-aFfLOdV7E-0_x+dRw1w4&~16a>s4#HC&Y(CCcQBCEH6SEBr=30yfq3cuR#FN=;t^~dXZ(cBI&fW zBL4xDv9y%j=Cv*#28Ax4bz~`dI8lyWoHg2m`Ls^QVDVW>tY)sQ*>vM=nsowpv@+qkH@(KaW?r<(Lu1wk5dkN0fNz}VS4Vp-B z{O7Qz%jBo(A>wx7zFc;(ykGdDaUX9EQ+sKaRYn;nI%MpxWvQczeYL*27Fcb4;ylZF9IPn|v%pMmxNT?nu)wVy1=etwZ=Yx;F6bAez?Ye#W{ma3_ zirro$9V-3#YoHauW>DOE=|XmUPP50xP_xI^@3Pb)p2QyeXNUUhH8byx#B7Ed9OH5d zpGZD(izw=wg%?M?N^>_mp{`y}83$YWwO1k6c<4aZ=vWa8h7EFAtG+`6Q z`1BuRK4=~+EvS%(HsnuA#5#UY#RQpI&OAetGe-Nf>iLq12Q|#i7&A01rsE~UOdu;H z4^=+~g4s(Oi(lHAD!Y`s(@)LY33?TBN@^h;D~AQ$I%MX-G3(`Wye3KpylSd7)K`2m zl1&tsD#+eKHl~nrTb1Pdr4k%QW`6$Vdnh}NeK|fTAe4zYDwN=4Y^|}&%QBl&_D?Qu zIQv)^O_fbsuB@tuwaNveZjpd$$-)@X=@je?M$o)4`ecNl${B?Y58RH8xHrrStTpZY zseWhq98UhJ&7dkiJZ7)`X5P~@{={SWaL-l8wnw>I z8sgxUw5x-LHZ)^dwmpTzWY*?c;7kyu@PhE=GM&^D<{2zEprvBBr5DyS0Wno<}?Wo65cpDn=M)7pleGL$UYK0hd=a$fqX1vo0LsUr{8q!55&0i1{68U+;^t z`pmPrXJy3Ym8dwK|Njo56={f@??cG5-rcZZKIxPX z!49WBdrU3TUqbt0pC0g(UZn|BAw_2*UZ#m4Xbx$L_Ynp;2-&$&zjsSJviBXos$HwD z4o5K0Yod_Z5HoW*y~tyN_#Nl6omSO}vW6aXZXF)iQYeGYgj-(bi?;!y9Hy&QU^jS} z1U_}_O-iM5t|F(8?>dWd-cefp>Fd+Uf|ts*j0C^1=zd|7{9fJsbGAf{mvvX98c0sk zdU(ZD?zLnPUt?@5YnU{Zy);imcI(TG6&0B`crolgUv*-&7K32f|LeRz?E6|hlbq=H z2cIY9c&u}%DFQVA7J0ksUX-Qnncxif>Oa$;Nrc&KqQSB39inbkU>k8`__D?!)Mn8EJ@t&~g^sWD%AYAz9A57tuB(FYdpqXW4q0lv)1Gf` zE<7d>$kxxI4Z8?ct9$4-{BNUj4Gn4&6{u;tYJK-Y)&bVk0x4=X>v`YnLX{#e!;feG z!b2I+Z?1d&+E7KPQvcg-9(>24R$7?hcZ+!tc6^OUI3&x@eHty&JpwIi4jf1#V8#$2 z^D|)@nKvz|=sgx*Jbj=X9{OX(ulP^w<0Wjxi@^hUp{u?pnyCW><((*v@q!`VW zuV()bObM8F%nP-{bP*b@1r<)XV`D7Y{baim`|04-35v>If z5V+d6;(kpP-sw{eHMP_0(-mC2JIS1J6`ENlP~R)ZZXC-;!4U#-Lfg=z^*|#cvVe$G z%e20+=>Eplr`g$c**(&#+`i(O@uHgX>q86gZ3`U6RLM8VsN6j%&XuDOb~{(YWL4b% z+q$fUhO~3*Rhf$TgvbB&S0`?y^{g;Q_N@jh6nzohn0v35${45`Kir1Da{)^oKiY=Z zc=CFScrTgf#K_E!8k-pDu(b6<_Auvr3PGsNV?_rmh#z@QG+Z$c5|UkOW&U?CkXVNq zMLxd!?bmwpIY@!XK=Y~+`pj5EQWDy*cTNo5z_Hb+Ry+O9I3A1T>HlZMYao+37V`+! z(wH+^HKTR~w;_0|RlG&4wYbZ&h|{jlY!actCifUymh7iyyAzQk)`v`a@W=fo^gtazg1KN;e2DNnijj(?5O2G{BEn?joAC& zlbTb;clFw}@*6rHV}@^x#_Mw*hziKI4W_(m&S*Ieqg!KPi`U%en|lnY7eU|@5x%ux z&j_pSM|LA){l(+{ZpRAu{MWk&AjtSsr#3&;jB(n-!^W2cg00HW{GzOMjl0WZ|Og54c)%}7IS&L>2;3JMxL_J6Lj?X6h_Ks=F97Qw>Gez8m4 zFUb5Ok85U7{ur|C3(_qGvafmen>XFRvi-{JLPh^vAx}DHuy`$^J3(vqXPilPv4y!; zuf3IIwXzudW}t*wS>ycz|E0Klf4y(8_foa-@?k7bgAEU3GSo>@8_Mh5A6h>RtO=de zYqjfOpEGf_MEk_NpH7Zyt@@!et?!UiF6!0%>E?^(dl8(|gqB}?WK#@NTPjT7h~Kzc zFee6Mchs>Dla7bspqRqdKszrYxtb`1BtA zALMZ;6{X@hwh?l2uYj^~J)~mo{kn%u={L>Y22NZ=>gzA+w9d0h^UFsEI zkdd09gD!yQlYQkPNih-_f>FrJLS)_Tx1<9mZ#%`v5IrvQu&}Xmot~T`@7;OycIQOs zFStjFh!pK3@ufrFtXwG*+vb1jbstJLKvQ2AW|yCC*PBf(SZ*bBdp8w#uWfpowhAgn z9W17%oXl@OyST2LCp@yl74@1#ouX0B`}eX@N|~_9>!|Pfy;q}f*g=`j)#~4uiLc`e zwT^=Fn7i)Yuur@Yi(fsTXJS7j7oWlGF5>;=Q{g&lG0$t~wew3`q2wL|HbYiUi9xdu zr zw@1FST}5W$5g*?(EB~fDgWFXrDZ|lGL)BwadMvGt`8OQwxD3~X>15rewuZGL%2UTU z1@3O}+soE?Sd9N^Gj>`n5PiIg>2qA&-C{FcZkkSN^!NPzXYc2I)4EfoyOTbFrEdcI zC=EXzWj^ywsde!cEFEGe;)bSd56pHH4XRu zfqEb2ja2R3C8r;fp_{N7x?ETN+4NMqYaCpO3quJ(EjQ73t&5e+L6H9%QGS8fdZ*6C_GOGZaSQqQaC zQQW&nNvLOO?;d^T7Sojs&f?Fm;LmR3&;HGyJ;LkoUkj*5JeN7-+N+4Uk3fpoA z^}dhvT1kLifjEBj;nBZ!C*9{GxD45f%ya|P`;VyaA5j!{lTwY2T@VTrgz5-FEd-$`f>GClQCb@GoIfa-XegQVDVf4U zD;W$A@$V_xN5jB%4dGNw-R~HYiem|YME$iIcREm zX=(*)YN0i?vNg3T1)95tS@jv1!Wo#l8JK7one-W%!atp;e!9lb%+HVJ=Vun+M+@*X zU*|_(Ux`5b+s53Yf#%jCFyW|L42aBjMtgt|2J&onk3e ze1x^hfu?KJ>=9D>zGf#7F01YxkuQpsmwQd*M9DvP_r)IcmEkAaCNGX@TV~|u$PW2t zOz!oA=`}sx8a_SVM1MhCu)SW!_@;rK4+H!tA&#PZyWtaZUG3@v`nPu*&NDyZ_z`=0 z)$m5}Ez65&jKq%^nycDY7dman-%^Ap!2LHE26B=Yni@Zvt@etTtO);Sk{n`ZG8!14 z>$Rgw_D1TVh=ia4SWGDN3FJS*r7ky`_Y6kvT*kVy6`dUBn(ck_&l+24H`yx{2<2Qe zhs!QEi~sR;i2zTZC=QPIw;=E zXU}aVUPTuJZIpw*%8qjHA;ar-S267xAr)%+bdC=%M)~IHMYZQ$Ftj_>dv4{@aQWfu z=s!PO;(tvz-mc$xGE?ak(v)$b%$L1>rFAA-Da6nHd|88d{o?Z(v=ZDcst@xzCd-8Q z{sD$QP%t-IQKHba4T&*g!0V;sr#?0M$J~0QQf0qdi;Vpfu>9&pd45~XCV5>WlT8uj z#LC?lx$|vNH-c%Ss18P%Y@BYtNY%syinrtj9*h~BVqHZ#Gw2eixJ#~sb;2gXx+sP! z8A8wH;&a>PWc9gc&bx0JzX+;pJGG@8_F&D{3u%YU^b9ik-?E^^!NKA{L%I?SfL#wpju_Ir2d z*H>8Uvm2iNzjZB1Ks$SwKQD8L zrYge)G7mlG%l0-&xFhAOv=I`u(U@gD&dc`aJn#9RUuYwA7OxAv)lz8vr-OR@ji9aK z8;t>9mcG$os~CC*7xP%5^)MnsVDyE*=Q~FIu=z-d72+p|C8kHVoSXqEm21MKS}0=|4Cl!VoS@{HUY#=m0mQr z{maV=U}RqSOKE|Tkr!aR(ZvRJO}#lc^s*Is(u$8_{Z`{xJ|?PEp{Yd?e$~M{SkK|Q z?)7(^=57(zvIZLuWF1UJYdKl}JdAFEJNJwJcgS{oGfnf46Wb)2 z6ZX6oiFB*eQ$yXITm-E7T{E*ozlNSAk2Q!M`4t)>sv%$QLD+eu=(P0_DQAg@M6Go- z_w31qLgr<4UJdcRr8e%1+mOADyX2+6CCa4oStRyT*@l2)_LL_1*7y!KwWlp?D~T=q=;4V3^FG57mQ6h4jUNfBQXR+sD~}IQo3vkq zJPqv@p_|>|5{KA|g$}k8{7o1E2F1co1ffiH+F>w+jr|||A08Us{0-vCYw3aPPC;J> zG)w6TgkOY)kH48FLt(ZCgx_^5x^>%7S2twn>+=DjCVvi*c#KUAiHAy0?1w)o4Wi_i zA+-Vsnti2*?11{Zl1fiJ79pP*===l7;KZ2b$nFrLTZ26O(&5hru;13nO|`TV(JNt&DN(J`eVT^WcfMJyrXRGj@-9ukM$|w{Px)?e6xY zfvV<+3JQFr(miVlCeovm>8sL3j!lg*Evg9UV^#0k8U$-lvO0nUW}$+(jY}}JqH}|? zGK;q@564q`jA6;?X4!vlETcx6X;0d>(CB=V0Y`pVNOU}V0W%ni6wGcr8MzY%hig7HbdW)O9IRW5~*2<4O4;@>kCoO8`|3) zd%KLxL0)oOS2S3@VjsRbRAET|SGY){#JlEVk%6OWiOc!e)g^@5(8g%MUjh3it&g|W zisG->ph}xPqpTMH%yty*(69Y%wT`z4_#x8r-JFF*Mf1k0EOqIJ(nX$PzgJ0&)=l3i z{*D)$yQa%Lc$MiO<`BS(EF5l86#0FC_+aPZQ`d3qlDa~7Mdt2qs+s-)-%`@Z7jspW zJ9{79Fo=REa&CuI#L@$Dp2|)+vE93LJ=>CUH;+oj);B9gmX_!KUZEcDteudycB|R! z>KLMxUvs-ruJ5YkpEo7`+AEuRKbd!;+1P>D$g@Y!Tgsx8P?QrkVs5-1R};e7W7YY7 zYUg2lzj-8`nwiN)bIm>1#6^xjWmOig^1X(#-~6TIOjJG>&31=RTH6fg_~V533#yGj zinpjp-M}%oIK6)pu7cz!K4?_ECAI(9r{&;aHauWR{{y~p>2f3lc)1Qy01fGl=1^blWX4X@3X4X?-d6L-k zLy`vL<{cdcc-X`SY}iuu?=Z z&<`~Bfp04C%~imxC)xqOTKXA&Rkjm+3M=6Od{Vg)6azoQnYEB+PvVa?N+^EI7E%0e z2MmTaW-W6;IHPT@0b_d7P4$=A;_5G-009fz;D7HdXtG(%XtG-z1W^zF3ZiOJ@mG&? zfq+>y#cy`o;AeGOG}(PGX|m~9fWUwtO7RAqu`l0%aqnaN)zvIG<15f1#TNaU-f+mKDQnQKWi3Ie`yO0jRH^jK!5ikK~$N3+*L)>#H)(1`@#R58YzCO0vDB5 znYFBi;Ebg?28^W{H`S{{MAWMvlu+FFco6)o2Dm8rfF?T>xcKy*Aga>BGPku5&e*~( zs=j;9p^};=d-xSi_A)TAIS7mx$6amI0tO_2fwK_5fCwgmz$`G7`j1&F>^qz>{^(9E zzj}2AFf(OPXMOmn=2^&p-@r>Q@?LPv2&(73NM=JEMGRi3#ndjh>_jYmz(J7SNdhAW5YqMz|Gv$CE@HAB%x)g<+t^wd z3zg%bG;SMP`v7Qqdg0G8DZjacWSA15MZ&x9K@6uP-MpdGOuHs1^#@XZ*nA2)@bOk$ zBW7+h%SX`Ag#Y_3tB^e(76{olL1D46IU)0+c!<~p3*S3Mx>v+Vof#;H#HUEpglVF6 z+SLE_2eqj*QtH0TCxfuHr2HAA)4`eEI)%c$Q{y+`A>Wj=bag_J+&gMMb>=(Jsco8j z0Ay-6_w-ufOXy%253MZBXbnROfvD72t7#}Mv0nRyR+!%TQKmLNWoXiVUd`P6;LP50 zqtlOA=zJT`H>FUYf;lkamh?bVn$hDM!Zs|zc#JeL;p=`C;n(~E(vOc=^3k$p#RrYBdFoB9#7B6#W6SMaSEoFxVAAH^{xA6zAA#KIk8y#zCdVrJD?XtS(}{6`P6dZ8fC!72 z=95T4(0!hJ^;NyoMIByFE!=Vgv)lM-z(o^Y&LG^PgV|M#8F0~tm$M1CJiSL~1Ozo} zIUjMy=)=N7Kkx29c;as=i5xmR9e=H3Vj{W`ze=sBXfeJ{>O^J`k?C9gvv}_lsQ0*h z&1D{&x<^9tML|w_qqRg4VnmVq11}f%uFRXfne{h$U2r$|suBiQCHk^uMqXJcn4<6ecbYU)7bosCO8u^>M>Ar^lu5Ix|8@U_e-nQ!F$V4(|gjKi-5Wv@x&vVH! z162Y6M5kw>a=l85qonbjX`y;+;+C3P)3gi8v;yyQvb_GZ5Bl>HnX#Qn`T~9v=gC*c zE>LidBwld%EK*L`D_mha9P-PyA+5EH4m=bT_4O|}IiQVR{xLemVQ`FPW+yiJB@bR2Q4i@pXC`!XHil z?H=_##pxzNhQr|>HY$X2DMCKbl~^A9|87HL?P2p!(u{kdEV$Ru?Np7;hnmfWgmgW! zA1yBZmSNwcooIFVOSb7hL$c}W?dNw#zuTyG9i1upec<5lIQwqDoS-+)?ofEk7GC%1Lgsm~}~ zIyjxI++dqdidwirj2R4QkVx5#EJb>!Z#d$RcvSSY`x}ngzWEv?C8bu+*FNA}y;Ktx;;aA&b*LSm^5jZbSLgSw0Y!0GKe#W+iDuC%b|A}63iW+>7jmp0+0g3O z@kOlti{+$(A7&{~lOi?&dPsu)ts>>RHFqc_txN=;T=du?t_?tZ$$GxYi?E;4y`A)( z*q(FB$1Ek>wb|qnPB?IxAp+pP8R;j$U9S(BU%|v4MM`C9J>t9m| zh@4*_`0uBWf{<-`TFD;vqa@_~hXK4u-TlZOQE~YF@aytCZ1s@NbQMus{b1_%?-Z!8 z6N*vE4mK49D=qQlqg8O}5#;$3%Jj&;rr^WHy4rn7g6;gOyzBP&2j}J^3n{p-VS9Uj zezf(j*y$$POHel}L~LCgtM%Sado%*8mxw% zP8W&2VtGzkV5vL0&eRRRz1zC0)@OAvZm62ET!NgxquGMBpEzassNH&}t9=sPA*H`k zzpK{LTjACzU(y+b{Q><*a=%vi2U?zc&I7hSC&;6a#+Sn5ujhF;Q2qXkO>b|hbIlnx zcFI)9Qw8RJN~MBL%%@2tNbr@ z*G6yU=+l6Auo`Mv8;*~0PJCX~_Cuz( zp~1Y9Ts2eE+NzFHD^IsKIsf&Uci7Qeue`Ff{Oj*Cvg*(jFUvdqu2eQKr=*IHwRUsJ z$SuI3?hAfj;+I_7Gxl1-oUGg6KxVnyRIB;8^ioI7AuO_{lxf;_T<+P2+DYVC4G)i* zn^P!h__Zv1>3V;O&A6$!$>>hK{m87T{hOy>pa;EQ5E)PXd@|6U$G61>!%I_ap(?AT zSKigP+~#Em7p_?itya~zR%BW22Zz*f_XWtl`Iud4RaP8wnaKZ&l{k3~Zud~9yXmWL zw~uNp8foF2d*_5F_t7(*++RN_a$ls6!?m^Pn>;HFM4a@7F1O)5cqT zr*v-ap}ltvPig(4NO^I2S^>&av2GMB7RbF0G8#p+))bKL8)D{*O` zWtybTTms!g2OP|khP-+axqmCK!Pi1Ptx_wp;N!FN)vse(Bbcw0Qp(>dt}f@oyefE> z|MJ#iR_No_;+H*RW-DlftF5Zu^#WVft-OGVvR4VnPZ*a4-GP6c2Ays9x~Xu=V1K$mv7hdgX5BeA=T6G zhHFYqEdw6!T85yiv7WbIgi>aWhtJJ9o1C@|_wGxEr#}nN-;kyG`OqkAF$ZbDsckiY z7^vLub?oMvw0>#T^ZGUC#`7|_FQ4KnKgLM|_$f|0Cay9jP8uCoiH?(wjjLo4k&cV2 zjEj?wkE@K2lTL`MOo)?CjH^tHlTM1OOp23Ej;l zcv$q}qKOx6!HY{KUUUU7=uNyBOuQIPyqHY9m`%J`OuR0ec(IyzT`}=uGx1_K@!~M? z;xzH%DtN(d;>Ba)#cSflXX16$#Os=g7r%*@fQi@jOM2hp^}3VqXnsovpDexZT)pnX zdx1>ul&sO(@G@<9zczeR8&0nS7uSIs>A>A};L$qpG97ro4t!GwPOl3W*M%GD!riG^ zqjlkBy6}Eo_@*wLUJov=2RG7#yX(QD_26ZC@cs(}Oq+UedVRRKKHMn0`Leq{JX#-K zMq`+O7i|y{hTRlyxG4-17H$w0hKUF_hzP?(g&RbLVYh@EZVAK0gd4FFi+@K%~g9$glgkg%p4T{1rCE*4oVVJUT!-@T5h z6jqg511&oiLdmAJhUU6%RTa`l$i(g_Wd8!+Lhqx)eU9``*4DAK<*tvu#DBDCu5|Ak zLts+y_QUa{t%)1VxjWTvd`noA^#R=uR^)$6rbFHqK zy+h^8dEpJ79HHn(m%}WymDs2&+k#BQ$+{jFuk`Y*%h6Tu2cM85&LX?FHdXImr5rDzG_+e7y9ifZ1es&j>m z8WsjO5)O4B?sFanaH3Du^5k?MS+6ylpV8lwj~^o3m)SU22XX@N3>lC(7E2oJ>m0R$ zPA}|amO&S{X#AUIc~z{{U97%QXS?HSe!WoRk0B*8`S#fT_GO*XWl)v)?pNX5a(!KM z5s7#iHnUZ5P)YW4dIvl2a%vAwVfy=@;H{b-`X;0)D<>>1I#L!=A4dASZAYKr?Pjk< zv{svDq$$g=Y&=*Wry^28*k>rck{=_5z4R$8F3FwYU&0_)25C%_~%={o_x*W;jJ=Zmd~^1vmTS2}ns8$jX?fWge)7vJ=?}Cu@opOBak3^FDRGSbyESw0 z>I1e98eW3L-Q9=QuqL-5=wC{St3Bq;)6a1(k~xplrXPD)VeVg=*tmqnRq$S{@{6(U zOZhcD7UVInfUB$O#$a6&^r4?y$}20#Y$x@U?YjjM^8Ft$$X=oA(k+&;(5lk~iAgBS z@x?HG8&jLD8Uh?xL#-tI(!w)aXd`DHBG9F)v9(dWAnpiqr~L6<8^=CcC-tU}M$oOX zPVq@YYZN{-(d)Z`SKj)E9b>DCYe)yb|3VACxS+l1LK1%Jr2>ni_qwQ$UlHWLC$tuj zX9|PJ1q3ugazg$lnf*Yr`E-FT@1t}Gd20t+9=3BjY}FNuB${SYd=xlk78%Dd6mZw{ zSM_vqoqxcET-2xNch%~VXk(^(X>h@+%+^kv;IH&0>&KghPrW;l0#lzUKB+!YdXn|y z<(l3~!!Nagool(vT5ue#NCua%irT<3PKwT0K-pQ}_BaDkBzzF}b9;>+LY{p%^ajw1f`aBeE87hrIfc7iGwcv$upjWZdUYq`T2rpc{2Px zalw+>#;naC?;nloq9vy~UGF32v7{Kj90q>t5`zZz^Y7bDGA{5M(wV3(6La!+`HnF7 z@*S+@i$t>~(lWyk(#N}Q3F?v~sdHwz2R-dbUN_CZ*VEt*p zLj#Vb6FvodW$Ty$4;?s`NBHzTAkYIIa5z>>`1CsW@U*k?G4hNS?Ks%*J`- z%H=_B>!Qz8zd?U)dOn67FFiCJcCgk?av;+mR2pw zO_a~h09XjxDT|f}31@JaDJz*MYZftnk(m?r@{O1aCv6Hx=ni@W&>*)Sn@)T>9lw|) zai~_WyPKIgfxiu|?ij&+#w|fxF>1|NMRFhw{Z{ zh5q8rcZXx6T^(d`KYu@Z%!6O?PE`8SGCVlwBP-IuQ)rcsnT- z_1u3NJj3zW(1m+itvYbxZL2r|S=BlaW;b~o_U5AE@7CI1dRrTJ6K3mlkNb}m656ak zP)t=Xp%HqPZ&r?wNbLKnit^c9*|heszm;7nz5F41!jYf(WA%hn^@MZuVjdjw(LbVx zkA}|bK4zD+e#{ZgFHI-t8{@`atNp!uw?^}BGBxX{E}TOTZg~M*{-y^X)q`{B!!7mU z$@=h7eK-dkZV87cgBL!);T)0Sbw8|3dTa`Q*qQV^D)`~>vs3$qqquk_hqq_U@1)4c zlJ7P)kL8x;_?x8I*f^pa>VJ3vk-Rez9w0(E6X^mXs!KEc#H?@UCE3`--!|00Ap6-F z%JoQGocp}D=j=ty!X?ZCJ!XLsv%oAFFnbxZa0Ro#j#=QuEO28McrgoCF$?^dh3ofb zZ(tT~VirU&3%4)};+O?V%z`v#K?buRhgnd-EGS|Ylramcn1ws{W;HMiTK8skFbjH^ z1vqBmE@r_9vv3cyV1ijN!z?`fOfzeNS+K$^*kBgy*xlZR#GoT%(6Q>FsWIrB7<6$A zx+(_U6oYP$LHEX>2V>CVG3fai^jZvhCk9Q7L6f7=v}iPoMkp5=Euax9ibmf?qm|HT zO*Gm7jW$K2t;v|*Iik@nXtXyP?Z+P#8iGbgqS3J$p{Zzejz(xP8eN4(H=)t(Xml?c zJ%~n+qtWwd^cotygH8$P5#SwBWShgmpQS%Z672vIP zk)4}OG~(y2SeJ~J_X;ZskK$~)NLcoMV(9vEL94@2r@V{QCHFzwbw!Sqz{YCL&$~R# zKuFwIselNJk zxG@@=2X+#kcG##Z5AJZ!SsET(&9Fs?&cXgl{0Di{c{Acf>uCwfZWrc`^u59%N^l358w#9GuaCg3fiFUuM=YzK=#Om6crma8Ecx`>;R3LRVtJnGFAM1_vj?`1m8 z4T~n+9tI27I|%V&s5-gzD1`OY&5m)E8xGKTCI$bLGhZZPSy;ZKiP@Tes-=ZyAG@se zpko^6som^jtKLDRTGVMqR-PvW?PIT!{(BV2jek@+h+>Jm-VqkzuHipos_B1yUY{G^ z^=+IHLU@zUIlgZ|ecT{&5(sP76%flb{T=^pA`E35by3IvIQ1F|_mZG-GKjj@;Y7~L zjkl?naOlguGPl$EgvtH~fo3k!NL68~Gt8OEzW*U7VNqb1(Y_&*iSXA@ZUm~)tYfmz zd`(G+TBK&0UT>l%lu9HBv`&K8rz!;mekE)^Jms#KlBOrLoG_vA=DcBt>M8dXrrMi- zqxkGL-yi;tz1pFMcSRi%gc&-*cet6LN)^T~Kk4gAbNSvAjB0C#7YOAc>r6sNPMrkw zxLSBuN!d2X#)<>y5^X~I7j#Jj?q``d8?PqqDDms`xlAJ4Pkc2%RwP|K_GAN5UUH);O z+cVmml4;pLiQcnJj2&ySDKN}5Zj61afXk~^u$v+&F)pXRrt>7#?l7_aYhv?M&)iV3 z%NmMgViSikc*YbtGO`)UU-GP&i~IsPn71>r8SlX6=9<<7#64kTp52s-Oo?MO8IHMP zGq8tGc@fd^58Av-CO&bada2(oTZCTFRV#~OA-^cz+(oK*P^a6pMexvO~|IM5H<;@+{0qb8O&Q7U)&>jHHxD~XK5 z4Ai&^s#Bk9`#Y1tBWk;3(ZgS45Yd!}pgreO2&23>2qg+1vTX54af_Q)8MtCt9=vSv z>*5x-OTxJf$G)zhWs67!Kyie+7+Q+bBBE>D)6RQx)hN%^K7h|4Hpsqu-|Ysmw6Wr^ z(Fc-cIPz=*m!^#nMet`@vJ zGDDn!$vu9c)S<}`vOMyoyv^J75Jb)On?botCmKlIcl+wSzGDpdFJ%FL@S<|p_bcac zQU4Q-pJ3FhA}IMYUQsJ|Ej=c~RlwAM^Apy|+W>Vt_+>veG%F9C%Ov}e0?Y$+)aGu06pwj6&B6``4@vVZr6A;G(M<`c=mJw}f zGcdaXkTh(*6dCi{t~>nVUYC`-B2~}f2oA+xy-#96%y$0Dt4z%upd2JdK_~`MT> zoT)SBWs$%)t2&_1MHmr1JjIYPKjrvdpsWh40|CCkk4apc_e32BXj?1dFT>Mk%+sX+ zTlVh2mJcP)6Y83Q@qPkJDY?uRx5w@UG4EZV;`DCXjqeh9jUNfD2if+9glH+{#|#^*m4l zj2d9X@8n#@{BKiW#mzUm zd13DU6EOcfjX-=1R9{ydup%XY3njJ!hPP7wB7qIW>^}kD;)c8!5YZnYX5}uSTdqL} z2Zk-~%w0-Ew6Qx`#=L1GaKpU6fzj9v%=6$jpoXJoV5GgmNoFZaOktR ze|72}$ZYu~3LL=@gzY~lu*E&?&~%BAaF!JiSh?!TsBi=~UC>5+SBCN_0g~xbB$Q78 zO~!oFc<}OJ1}}&z;1%dM-a$ub4F+bgB8X_bF>sOZENlifl{@}hATNd1fhx`XHt&-y z|1EB{%JV8NCtX=Hu)vohzC(uirWndX_ss!#DXPyBc z&^?U~NGXA#83Tt9YFIG)65JryA{{~70P_|%mYX(X-dqRR{<{Q}mU1DY?R~)baM6|V zw}i-P8(~s+237{@;jjfn9*6`#zG@c0GD9DT?wKMY8Y%(S z=Sg5m`3EA$R)cI43hm_&v;wt~`2<+Erv)CD4})|U7Ur5;z_)qpv_IHNixH#1aU8jC zrTtdmR(7gRX2`gmw?Yp9&qV-SC;-3%@bm=`b6dNso+c%V=uax-2VjL402nO*NB0=# zZh^>k27P8=jzb{;)Ruqs{=VJ|0CxZYX|ZkI-)+PIn5dY6r8Mhxk2jM^L2XYTR!59=P19ys_06WF$z+&-qWVn+Y zGLS*@7#=%x{N%JUu;nAT3P~ERvg!YZ>;G#QQAtsmI$^A>-gvsf1%cD_`Y-B!9Mqg!w=Rd) z-w9s3s1%6mEQIEdpc6rNrjph=$L7;Ltox=ocsCOibYIGLYB-h%E_>>0CZ8rskv2)w zWky(gsBiT5Z(peE4>x4r8eMGM^O~xuf#+7T?xO4yj&(OF?Iz30*SDOzuRQbU-ZUSN zYd;DXbgD>Auo=Z!cyTdUc&UxJR$%LYqL+W%HJIA4x7c=@c-(_U6c&E1=Ew`qw7wg9 zOfSQ_$@xTNpfRNIS;!sJilV7J+Z}~m<%QL*QqEA28^p8lacMaW*#P2MIWt6RQ4Y1CX zi9nu5+dt%|<_M-=3I_%=5_(db?>;b+uQN-Z?l*DMd}6SdB`YSIw^WCCE%LMwR*x=x zm@adrJo@NvO{9Gd!teBXQS6dmq;ZA!jZ7u;yRF|#Z%p&;C#~A1ITm4G#uT=Z#I9ha zGE-03aFsG2P?D7Xsy_w_M)eHl%dREd*t8W3qVvCH=uNZ^?uoCZPG{P?pZ0;)?^dQ^azrs6T0^N& zGwbIoae+00)o(kQecjN4>%%t<4l||_`qe0{8gIEiS!pb8DjeT>o`5gIBdDHw zHf9G|rd2+BbR}Qxj1>u3$7if>XDq2R)`#y;JQZUHgy~rGWzXm+AmWeAcGMnWTO3bC4zGJU~Rw5OU5$ZTM$TOAMtMh6*r!&zKhgl5&Pz z1W4ly;>nqcGsJl$SI^~4Lw@jDYZ zLy$6(w6u4~tG=8Ei0BzY4iJMggc2Ywcjha z27u7E-4t@Ap!@j^{_E?l^)ti-Ag+`EvHDeiYn?Ku;0ybimY|Ep>Od)1!541OZCt(b z;+i_p!FQ$ujN_MU01?$7-|2GJt#xP*YBlZ0arjKWzO}sk?~XMzy_9WNh(n6gmrp{& zqivIoB;DuR)4gj*I^)rPNm*0(C#%0rkE>%1#O!glZpp1v4#`po^b_Ovn+d1pYpL}9 zvE$qAt`dW7p5l4$a1p8W+KbXW`v=t)4Fv4BDCDXBNZ2v+;e#YsrK<2UYshUp@|F!} zhE4yGn^9oVJrlA!+RUF!_&=FQe=^bdWMcBk#9>B6HHI4#cNG(N9TRsG6L$*}Ct3f} z_IgyjL+NFg+z}*8CrRzLt~FDf&+ajuSEhiww$mE5IFzCm$R`9TS>d54i^#Kk2%t>v zxszw8ovLl@*}VuJXI!WTHg?Pe5iP-e1*cuaoPW@ZiXo`eRZ+{7=OnvkP*Xcb={A+@_?n?kiheJ+$8wjSXo|Lz$R zu{4Q+K9CT#kCWzq=Aux*mjmqU7g}{Q1>!2-g8g)wKY~kB52t^3pB|jxmmF*%r3XjQ z;vP0K&($gY`9ACCKk`>)EvILTu@1pA@1iak|gF-r?pV^ z=ffFJS-)@2A6U;sZJ7M+vF`OwR2oUsXs`uq-l)iV>7*v`z?MQ zS}#v#_A-Yzq4lNJXDyKF^JBlVUuSGCw5bN_2^7-?V~fV`54uO{w4+NV}8Cpf+$ z_k%suML3jP$*}baRCuE;| zl>hS>;@+`SG>x*u;mpF{{5ORDz`9eCbRdPS79u5CUkasPm!(rXu*{Y02uaz~RaP)o z>Cl{D?a+L4^QFb6m5IcmMwl9jLeat6^52hn=d7Hki-ToBR{jaCTa>Lj74RpBIXS`Vh52Si0vu%4%4zIjg!AU zp1%tH8rAayIOtA&|I_@p%d7s2qn#(~b}HhI)2oB_z^6*RSKh;-MEEmOH#p|rL|Zc9 z{BBsYUrFNCQxOYr&kDSJ+()(I9ceVxg$q!6a6B=$r^au$UAWtjYdUn*ZfNy(hi8kB zfSsN7@kGRxfX||8F{*V%CNBS;bFFy0M0}rH9r?QFgUzUOvd z#8&bk_!E) z^XkQaqeg34%T&!kNL2P*UKYQ*8nY7i`ENSZ%TU zWn-x`zwE{HJV@bSQmcR|GjQ~MJz{en6=|#bo)ylUX5~8J-Y10Hhh>Tq zM;~=8J`D`5_a_>mM3ad8WfsNa4%Fa{uw9kc?g<$i7kKON6&TEBn|LI=Z+ub^o#{QE z$W%ex`-W1vGO1&;;ul@NeR%1Y2(5&A@jf^W_PpIky;%PLNIL6)CZ9fl(@3L8D5a!; zlysw{NT-0bh_uo@5D7sVq)WO(T3{gE3ew%(jvRa5h#8cYWF!w=Yqb%qccg&3=7u-*MKWKtdXjw5M);CSetX{g8TAC z+JqzC^XrV|c?TA3bLUq(&q3~mC*Bjx3H(?V zTghVV27`N<0pnx`96^$vD8nL$+AqvQ8F);+3ve5S6cF?appfnalK2#$des@Iz90iL zmI2aAZ2&l(g%(Kbi+ixbW(#m%2|%ENu7Col5Pp=4l@cDYE7+Ca! z2-FH5fm$P|IRm@d3<2!3lPSV+R1h$2>=bZUP|lBd{taN2b^M(TBP*xCN?oubvsbwfR`$<;lpAg4PBCz4V7Cdj->fUJ#%%;_ zO^MxV88|)A?`;KSw%(3}0as`XT`7)905+k03f2QJ0|3n@jG)HVth=KEqWFhLZU?|> zF)#3uJ<=Bp>45_)$#O&)BrgF`NDl+y8e;?zR0hIs?iln*wE61_kG=){Xid>a6dqB8=gDRZ;&kzX^g@?OZ=3*-O|V6FGS zR#3wQ-txhKC$%?5?=`_Cz$*v9a&W|sXsj&Tozvz~#8te&Vd^M=Acug*YCnLAu>)!n z6(Mje#$dB#DcOK`%-#~Pug?L>V0$Ruvp;9UFiZnriatJ=*4HIt1MVcO24eJfz-7t^ ziUaV|DL|7%V1Vbm8RKRgZUM6PuMg$ZtN;?;yv2Gxz+Z7VfMJyHB?t^E0dl200jTHK zHo$?39|1mf0$?;e50ExlbE%mU1@Ljz6W zfT^62A2OB$V!ld#>c80_b4jg91|VFY1N<-8{|88LEPs|_0Ojwn%N#}zSm^C$z*JIA@B%2Bx?qV4m-g#(zK9t!l;f_oIT z65zLqH_%dG1-9CQbuj7{m1ib^(`a?G^x5MKc%AtJ5ZujMzdgY#oDXW{>u>B9P*{HQ z_v3{SI03CszzKo7iUoUo1ZSSRua@{&6I@W3q<}bn6j{m501$kgM5AY!1Kgin^k;&( z`nsKM90z0RlOB4u&4?0Q-Xl8}0`=KE=*hO)UoY2^9eKoXlY606^!9 zDZrxogVs=wS#Sy#Tft?8``#^6r2sVn5`;~wA=qIO;1nRQ0g7LwM$cHirU_aw47b4roRk(g2rwrZ+K^03Frb|D7N`OZ2H@;xE{dWb2RVc0kKj_#WCk4m zLE{`A)cDiE=aP(R0z{WDsKB9qX0-rK*K$w5{fZbxt3< zO$X~H*hOn&fEzAT0-S1J7?_j^IAMe{z@e!KfGAViK{CTuxJotV3OdGMH~^;>5@qns z)Zvai;G+OMk#8JhI#6{PDmX>aA%KYF2@bkwZK#tBnfXr^Z z0YGwNXNLCULCh^m)w)HgRv@XkC9@T`P*#c+0MWJw;#N;Bt@HPYLtG}JziX(K>W?BQy6PyMHP(u&cnK-+b@UW(eKJf5V& zl})SLFkUOitPR3pQ@K!TUn{Sjw*U>^YJVRR5W#p@SDuZfd?AvhG0vd0nnZ@KazdTZ zmcNLNCD~Gn)u>8vr)jWe1ACI0fAOP+ZOlKN7XyuAwYpZ+mp|nBR<>?3_(ziJ!M}!FQLVmeD0f z%C*h+#c(?vGP3@u+L8XY^5R|}BO}MSI&;kaXK01LwOAMS%yjeyZFpf<&*|h_`{;P` z(dqp0iG!hlZ8#kK58$$P43Th4MqltZ$rQWy)^U%h4Ge6nv-5DwMT36-W>971uL;${ zBjQ|fc_9-noEL4jQgd6VeJeA-+36An=FyXm6|qnotdNeeu~HkHt$MiZfY!Xw?fdaW zoD`id?ES&to^-k>flRv7#Rg;tQ&Q>Qfet)#QJp_PhhCwu<)2;#Rl`DI*W28rP&nwe z-QrgFgInG5x4LheY;T*WLDRq6rmov2=i4UQ!$jfv?3H*0#&F^N?3KT_`As$hHgI7S zjfj(h{I-A&3e2|!3{Vg_dKdmrS|(iaE||~%92Cew!R5B_02FEh!u~#I3|FKtRD8@| z9m@YHOdI&E$O^jRZwnlt@b?pA_`llBlrKMlc(DLL|zsD z{GmN@7VlVc@X{`Sul7{?JfkpokGi}hBwRw9ghQ>CkvH)ly##DDQgA+xo}*b#?4U_q z$}A}_eNaSGs)%S*m6=mrttaHY)o`Rb>}rE=9ei1Gr|~W=xqVZnmt=FKQ#MQv@!gSa zU30c{XZ;;{4Tt19qU3;O-=Od572?f#Ew%OGwvZo6$NO8VO8#DBvapdbuojcdB6R5lzUsir|0sFBsme=vU7UFK>Ra(n+&|75)-OYd zg303dBdXdHuz4-%R|bhDYQ~RnYF@q+rFJu3ZQ-9UWMLERudqo$Q+c`Tsj9MBsq#`t zG%BT?U$3w7ZL>oOiOw?{nZL@cR<>@(*)|)9z}lyagrh22qSpEfpSPyHCdaEouD3)C z;{P~ZeVcIB>Cau~O4q38HZbUmL)B#@T4|pw)a#t4!^_sBTwZZjBC<3;T$0#i>klOS zk|6Sm2{2)oOEvx-?rNOxyV4}XZG1>jCOdrT)%Q%ICidm*_-LIzl5*Hz?$t8eRdY2aUq}?`$?oU-vk+w)#!h5-U4%|% zA1|-NQ5<1W{zT=(N(VFbKm#LFQa$huCEN4FA6r8N2cs#64F&az28Fe0-m;FaZn+a) zM>Z`(8|4PGk#Yar#%=_<91|XjmxhnFB4ul33NuIlaOn~q7ZrYJvzpQ1?HH30?N5WWoC^x{RanpW|)`agv?-f?E-p z!`on}z=WT)n$kUte0KXdsn3uw{B=!U@&&d3^4;}Np@y|(-F`RXm8m*;qw)jxJ(7~2 z_ykPM)J)7AOw3|T%&(Z34Vjp2nV7wqn8TTvQ$OREFfsr9j6cA{Jj=wq%fx)m#7w}< zOwG*9!OSei%>0U(*^rspmYLbR%hxTaD>im0Hg-HVcK$`sdTi|eiy%a7>`iPec3dn` zTr7E9ENxsYb6hNET&zG`tVCR_TwJV5T&#{o?HjX6b+hT*x3lVI^SN&qU(@Qn7S^5; zX44m8n-*cy7iF6kWz!d9n-*i!7uTK=XVaHpo0eeHmt>okWYd>oo0eiDLeJ$Vj2=~& zR_Z`!Bf`t&e;Pgd8&ojai0QDd+(e0yZMvT>e4OBcs$oq)jN2%fAPDK!t=Y_)%J~ld+u4-B{bCG`&C3pTA^N zeQUdC{{F=$>|RfDB8>*=$>+l*?+Y37mhstxpZb?_ZR+s5#Ec%vDalZ=5n1H&$H$C* zkXMp<$VOz_fF?_5f15boCLeDT=Y}I0Lf6~G{WkHuO}y24|1D#X@*-~&tQC+D-X`~N z6PnwE={9+KoABQz;?4g>t`ux+!gBUKA!#zn**ylA|}MTYd(zy zK#Q@{!qZ?o5fca2%^~xxZ>Q@iw+$aeOt|vG_03@1vEil++?iy=8p5K8Te!$zFVyD(O8pEmBqPLPo94YQR-W(uVYu3;DoW@PB z11zovy3hGVOol6fmv@1emaX^R^ir@TFa@uwGjI~PT}0~wpG9JkDw-W+a12dW1ZCXlTWv}@6bI^S@n%8-Po zd3L+Q3;9Cc365Ot)QEbU)R+xt^<2F*mJ9)-}aSv+aKrSJer|!3`*#f9qp2$$Fm!_4Llltc};T^o{15k~~%V zkUKNQ)`PpFEJeGgMgkS^??u$3Q&fU`Gx-k9E?t$@)B3)rkrKh27ORMhZ#{&DtHs5$ zI53B)g%6djIToMIth!XMxZp7x^ZGkG-u>f1J}vnp@e-`3x1%w>X^k%!rtB4T~WF(C@X-p0a) zGvjw{doPaD%Oh#T3&>_8ywzbRuP$gXF;!>0;zC>CjVpJd35Wjg4-)my;W5Le4acY0 zHya5A!G=9ogzu;)BJ%9gC3^TV);BkzNnyq4u0{ z10t)`7E$@)S`K1C?@5PTFR9lY(%(}LoZp6_g$6er_N!qF3-2HbRGtY5Z&X|%_U+G1 zRv0tuv0Q}(i0U6s{3xZ$8rh9`?q+VfQ10&D!hy5q=@pe0(kU&j z9HrW1f3VG~x9@qDB{Jbx`0%G^W23uy(u~zr;)a^qU#;u2DBg2JJ=Oiix@6C`wZR?g zp-Yn)!5O4f22>H1a|zY+5pj3x9H%RL@OysY9WVJpqu0{eu!HDtxWd^*&MQsVCD!`q zCRbCa4?ndq)nYC0?WIbjf0K@FTus6KD25tIA6BuVyZ#f^)@#^#0pnr67 zcuB!&iyr8zPY}w}Yaq=E`sGtBIkU=vZlj@U3Q`7O%rBr;0a(X$%2A7Ewp~qe)CPDyTJGjvCPF3H?~3x280S^i;$IsvK7eH(Q1q}W_-JV2WNIM74& z#1_N+BEHt2_%`Zb>HX`$5HeuOZ)31T#vm+(Wgy4|AT06fU?o}4)MUy^vH697SRx>n zof?RRgY~!so9=_k1Xd>xu*DlhC7Sus!&pnsrDPDu?%SYGfR17y{TK+P0z%vDfKXYm zay(O@C*rM&G~pu9`=1OjjK~r+Y*te&6%H7*F@d2MC7^c}3?&AB)!Z(!ATXd9EXz_h zFdzsR{XrQRa0K?uE|6yGUy6+c{)w$WdaejUNqOtIK5(2CIKGtwr20%hd8`gXSqEff z)dLyMU{n280*+_jI?iH?fx!s2P;@EYoiu48=|@2z2;f;&ZE`^GT2swuDzNKxhu>k@ zWKobZohC%fOkliiz2^l&^BAlYVVpn!6L_Cz0CxQbX3GGFOuvAQ1a{S<(*#nv|3(*s zy~78*d#k%>hl0R|qZFv8)}LAG;ZZX7I0jauzdj+`;HMs%*KhaKwz{IL2b0P!6oe|E zY_6k~Q>eFatJ1AD@TaUiv>M(HzqvYe;9AKn+l9m0P$PN^u!`u=Eh$I^+P;bk5C3_3 zxBzn(Vzq@7Y$jmF8E7gVlISUt@M_EMV3&<#nN!1ezOM7lj0wmr=6H3U>g7Q}x{&OB3^5-^h z$qb3Uza)KN_%LsdCnh*L{O+jgs?At23RPAzQTK3C7iln*6`vVaCCF)`SW?B$WnRSc zCgoc!3*iS(_jMs*16js;H!)Ysr18k_dnLu2R zdr!)G`86)oH(tBubKv)$OBM4KP1bh2v?MOjV~H)MacI^psuZy7S)HzF?PjE%~M+MsF3?jGSx4($WOW(Fa=%nzg!z@ZT9f%YvHPB5R%Gg2m1zPT+h zO-7Vy9|uT;%Ihw_rcm}J|8po6pX$SXrlSm(DE!$J5lk5_OpB`S@mjmcoYq9)gy2E@^?A!v zY>%LmfYy-Qx&*_c=R{l|pBI#z?w8Xs<(<(iE+5I4=Zxzd)Evub8aS^H(<^VJAz|SOw0R)f$E9*p>5g*2|InmC%TZrR&Xb4M~9-GNH$$pW1&bmK^~2T za@6o~IcL7`9~%#kI*{xQfJVsa=UZZ0@>j4>P&6iZcmVypM6&Y!z5~ny75&%uIA*OlDVOOVfW{*j2gX<)Qhf1#4bqb z3D}ATE_mu6=f(lko&LPXaKe2>Maqx&)eHGV|n=#K(3VLzHuX!T58j>H$kT=Yw zpq9=~bgIuu5)~1r?C@hPDLO+_+4wun?mA=F!I#)o_R07a-MU1DjHX8sDW^2_jup?9 zbjAFXQ{gfX5KDNqpC4b8bm$MSmS+_65pz?+LZRB zCB8}BPlQ9MnhDMVuWoIL7iW^s5FgQEAG;=VTqi59;5e^5w^TW`4 zt==A(ITgU+A9~cT_OydsS{7}YHGi4?5yR&rvR9TDU)t{>JGCG9tvtZ<`$RiEq7m>p zbL&fqf2jH|wTmS%Ea`|$;3GEaUDb;(x(yhicb*_+iRk<=x<7@nAv>QD`3%)24YlDN_?Z2+0|2O$g zFD%RP`%RSjb`3Sl7PT0z_1Jd|6vCb?L&kWkA#M-IsLAO0z)yleXs{aul74HpyAf4T zdISaABI%2tPHk~n}4e#x@UjW z=MRS=mtytApF8(5>$S$yQ$I#aW$I?Vw-7p^%(vp8WOa;zsaCRG%1{o#WuI#L?IW9f zid@5-8ZQ}8^(l<;96Fj89Em1lMiP9VxO>C?++^JCrz|J=L(a*%ZkJI%ygsz!)A(}S zWxYo*kwey-jr_WyA3VKGhke(1z2DJYtN3KGS4YobqR~dTF(oBmTdE<%{l}2Hjh*7E zZTW?E4_qh^W|vZ0lE9w<6L&}}KWjs8_I?dBN_VrrY+48qjbgc3^U{%UK@4E`BDk+O zh$1HwB+e%_9y(mAIMnP^k9sVd=_gKUo_EHpIK?Rn&Brm49IIds?`G^AV=oT+W)7Fg zu^;Lr6?ptqYIZ*}&g+^s^BMT3qf|2ZcAC{S|K#7p=ML%O&;N|9yy;-Toxbaq_%icX!4}VyJ#mKWq2H1xIVnxXgCVu>~r5TQ7c? zzIllHUgQ%aP2FB2#?b_ae5kj*j6Bc#=zZh$NOBWtX8lFN$u<^`XT zOWSy3T>1XxZ_`SLt}Sb!?PTPuX$9+&w7BfPWS)(gR=9bPf4CF$DOl-kTfNg--&s?k zJmp0pZAMc-P%`rL6~1d=_2fyQmi9csw)5rV{IaI(qL6|2|Rcsv=tos8ar zPb@G?w!4|_)_Z<#ZV%pl<>9QM!_3>u!uk%%RX%ymq^7i(!(^lyW1^&@Wjy@wv0<#$ zlZsN3gt(BedMqQ1bBMx>pcf_g5Da6?kvdOynZO}dtvu9zSvS#d`vgT87S50jNJuapdMTMpXD)+Ci3mZW0eU=hN=r-d~J*jP1 zIhG&YlK(w0U69>dr3+Wg9)BuRP414EeKq-B%`}5WL4*pB(gmbE)dccg9~g7U1xy-r zcGyO!FV$*#c$7`L92qczoQk$t_X$Y_`S-v{j5w{w7LFUKrcUhdj|_$^Jvh08TC#oH z*0h(FYC*d7UwZUkda+b{c64OGci*FopU!w!i?(@!kk0tkM7wRo=5lTK_EPPKG8%G4 z56(9n3PJabOX$lDnLpuGV3Q~Ykr|hi6ISRb1WaD31Wz`Vw%INlh_DwP88qld;&U`l zJbv?235&vbmjL9SDU3PDGs5q1HGc!P@9QkpPSrZmkehmxy}j+FGJe%oTOu31T-&rV zqr)(GWS|19=JQsOduYs|iCfWC{&>02lrKt7mfET9ee$9A-Bq0tS3H`#VI?%z z!#-0gJ4Rnf7^R4xp=7RwVdD@YiHC*7>sQA%ba8#PI0ad+{)3VHff9_rpQs+6%IDoI z$ee0%+MXh~-D(AeQSmE{@S@t~iGHP=@r2R{N>t(2z1i)|r0Q4J_ok(xwO+oae02D3 z=FV|#dGfVJi(a^zi*b-%*+Ti@R`U;7Wt}j`jzPX-U0pv?s*BY%8?h~55kJvap^xB; zP)dC*cPO@?cTC`^bGTShcW=pZ6!!{MTvLWDmFzwJX!Lt~+%{xC7ayzZQ5+v#8{H0G`JVOOneKF~zB3Gz{N+*({Uo*2D8rhcWCEfekV;4~}r}@S3fin~oR*Yx#w^|R>agV8v;x;e6X!w-|i1DqQ%w#VF8%#>1&@@%a? z>=$=kd@PF(BWI`cVYFv)mqf@z8*G$R9vYNdJ?YZQhh%l8yMS^qkd6hos6$PEi0EG% z#3#B09eOQ6;EAS?hQtv>Hih<`s10&=X@lrX+h7{F$VEGB6qJd9GM7e| zi8hD?NdBR_1a0CU!RpB`pbQKo^cNoqdm!llp6m!lXNUIZ$rALc5?!{$9}0ABgE+xp zChQCN6c}WTj)FU2qujOqp(@=a7$f!teYdfe5hkj>1_O%fz#)ymL{YZmkd|Sh=q)OW zHqfE^ZGUJJ_?aiV1UDiiGqxVxdBAr(&V%KNCGl z={w5YVmX2}(_JjSz(vxx8EesEqw2>op%EYv@vjZ`llTIrLxMCRqL${T0FR0Qh8rxw z$$=YON01RPxDOvG#L)&}14DB$keTM#C@l$=J(FX%C?%Z|WCTg%fKM{}Nqoti3dYkc z39Ypm<#dTDceS%PR;rUl*#cY~F&hNE%}o!V-j&JBpT$#0K`dsZ6pz?63z z65<-dMBb#|WL8&0tLZD72zM1*2W;qi7k`rEHzg;@=*QEU9ixTP3J#&7j8^27blwXW z|5|f;l041CriuP54yE<_>(?bG3h~7p=UYa#E_o?UeFis6X6dQ+0kz%1DorUIt-ntX zIe$wCa_X3HSce@P3%@k+Uiy&kJ;mdj?l$l0+7gJMQ~93YrIWh7o4A`V4F12R2O@Fe zI3E&pk8Wz;A!J}OumbPgdty6Z#Y!IKekbH#c>NMJHUF`sQ7i945ASqrGolTN%My4w z>wt4AR4@7r4ksQ|+n})Ull|oq8E|$k#gG9RxiLT2d#b73S-i8eQyiJ33%6Ko| z`R-tpV$$$S9>|O~iGELdlQjtE-$$czux*q?yv#_jRt3o1V1;QES`JIFWi;SA4CgNf<1cAs;p z)O0sUb9?4KK>BA%l7sq%6{P&RX2;`T9G)Xbb2ZyoF)*y=rxu^rPgoSE0 z_%&p|7b!A3ZK8g&U{}s9)Hk5+XIEMK!bQliOLC|*p-J4bWG+9f_GDJZUgz@1L@@^m z^_$xH37Z`*Lqwp|&b%Fmy@24}=;WK@+5nhi9;(VZlgfr!Wm1anJTm*Nt;*}1=Z|`U zk(95|VJl7)3*Lm6&J7V_08u3>(U!A~&OUdbaqP$`B`3FveV@(-qxZack5ypmv`4@1 zV`y@d>-LE2?h6vry%%9>iRv?WcUoLuWqCA*=eK=b+2k}O{magC$LNI_DTaL{PBsT0 zj>}2=lW4r&uNcy?s#ry;s-OfxsvskfR}k^wb0B!|GpS7P=kJ=`U*K(f@^!r9Nhl6gkRRx81vwVS*H3VC zd32m^>-%Z)?bi2E#5o^kiEY;5jbfEI&k}Zy9n70H>DyXJ=I}7w3kv#UZ``&-Cc30sPq^XXEgR{e+ibe+>b(^_=T{D zkQ=zBZt%SM%H@djuwMnA=NtX(r0(OUs1|d2m|-55-&w!?&J;UbV#YWDZdcNDqL5SK z)~l-`@xH?ED7Jm5?vJrW$8(t>a(#JJOGI!_{>{j1j%}Ha)sLqUwYaK+4I#|byEWm0 ze-RX4=~RrX#!F_#U&H6rQ{$j?B$Rk5)g9{e;xf~x;+LOhx&*v@S~){#Pen=AQB%e% z?%rsE_@S57G~DC)@5W?PDD=h@GX2STO-*D9q3c#kCHs86xu$MI-F!8Lq%o(Y+U}fz z-xI$+uL``mu{VWDiA}+Y#nF;;O`(A!w71otZb+uvP`LZ1&?}ruNM39T%0l1Ew8Xl0 z3D03`Q2v$%Nd$yB`(^|R2pC34Sw=va zPsNanvUKNCw0nd8pzin3*0ciWutTKfn}9m2LFg)AyQNf{>Ot^RIi& z)vTyJQWN_6rf1?Zklah=m!)B1JncmJ-4MHZ<$==nGI*scNyfW<)TyUT=!n8q^q_Ar zPrVSr%yxpxYc1mMc^Z6jVz-TY;==&D;h`(H0VB>x&4bNY4|k2>0ZonCDwP6!e9FBH(HX_|UG_KolhWt-*UcdawN z12+%PXzCXo+zhq%*i$a)>a6i#&&od{L{2m2! zkQ-k9A}R&_=o9)y882bmsZ1Kq(mR~2FT^-)?*q|~e*8kam@!40E8{_{wdX-gEagFa zj6>xgs7rHDM2Xh3vx-22cYAaM$q9rnnlCpF(kqK(1n9p*^iI;p%Vv_XAJn|5Qv_H z2BwqYEQwH|f6wAU+c(7ujs1nj4|FT@wxMBx*aTCXqV4ROq5ZDOVJE=!&!^Qfe1{gg z@(XRs6e|og?hMF(Z{tDR2G)NuL%YvGh?e4}o$%lI9YRc=c^ph>ou3)7w z=|pIMtiMe(@V^~No$E)N%bFGXi1rU&^Ji@FA4xqLGt$wA_whUBx!jO;=nBnumU;rZ z78!y0f_J)B@Wx&Bdvn~;4eD5EQjj=TmAd@mtK>Tp%V+$t-lH*-Lv%K3Tp1ozH6JX$bNLc78{2wY)WRCtEE}Q|?A*YR-uAOiPce;o9GN5%UalJ=t z`c=vjG$h&_zIcRe__i`od(6Bup^)5}g~cHV%jla(+}O8{x>j<2}LL zTpoH)BTC{Z@317^{GhR23VE~@Zj(x?)Zb5>|eE`X0smqif3=OL&&R>(leN7 z(*;$vwQyhdtXzfn#EHFR{e{t$_BbMsk8=0D6&@vJ`T5_lZ{$UDb1#N0cX$OFT)$6A zG^GB%*<3dW9Z?rxI_2aXEDL{F$6~x2bDlfnjN-4FL=Pl&c))ySJSiJWMZ@)QQ=9b4 z#g6kV{e#lh=4H|pOgi{Cv@>G~Gqc0dj$0XG>PiUfCc&g(pj$te`@t2d8kR;dA4T$9Y+b2*XPON;-0~ zz>Pa!rVTq^(#3s#xgFk>J1|1Po!83Mnl`Rc$LVM1n`gIk0l)4ZWPw?0ej`K?heO|GADoAZbB*M#mzlIsNI9+|pNJ907w zJu)BOYaTH>OYu9IQk3)^#EenP%E2uz{uAT(a9dF-#w9mX$&E_j^6hAhpXhY<-!(EN zg)25o#>?E5=$+bo#6@%!%h5ZHpzuwOMl+oWR6a6**4qLK6b^0+(sx6&g}!~b<4Q{u zqVC*fJQ&yVF4Jk_4uOD2l?D5h=v~UEv1$@iULsttnKJz<5rz$>F@qSDuFW`gewx^Z zYGmybIOui~N?3+MciSeG8K#;(q18DI8IMh4)cFyD$@(4g&z##PAo8{efsgIxjB`9{ zR`;g78u9Cf|F%sy7MbqWKAaLY#x@)oZ<}b*sYF;YOnJql)nzb#D(k?mL@>RnRD4H2 zb>(@RKb~r01cv;$Yd5cmWe8*SFPr@Vj~Z63tS!K)8-81{t&DA0q0>H5j8m6U@~NzT z<&)eq@Ae6WkL?V}U8cL7x5mG#M3~>TyJ<$N^E1LSbidy=Q3b4yL8~JMzNCHlX{*8h z?WbB=+C+vQY2SS-4O~5&rqv1A{=T=Pe5$QOo!moxYdz@m>C{K`yeNVwI!Znk!R5n2 z=w{0dJ~z-(i>n9wT0zE29f(TL|dOR<4xm3rYLB$qCK@eAe-4RGOAqJFvWWv=5_``UH= z>5z`M%!iXD73<3TBOjwE$huJvSenT(jYsV2&7WOacXd6c6?sFmQn9=eEk8Gu6yX>4 z@ni2tV#*Mj(GMSc-9W)7dwGRar-`~dC)4d)eVm&3z-(Uuo##*XM~=7_zaRJ`yb!W$ z`z5@w)BepjMD2ZFRo{W{`F%5&OuX)ha8PwXDjb`h1p}2k3h&7 z;meBBqs(oGiwdUmpO1UO_j4Bna2+IBMs12BRK~fK7uk){nzfN4W?$l^T#7GVbyANG zIk!ogpJz(3>e$z8thG{KMu~fB7jkd-X)YrA7HQ)|PDFek-B*+x%VUf7tNZ9Xr;9h) z!$}Sb_dr4Awm?1F!W`(!6WfT^PhBR$cDVS`lek7911Z;S=-J-q0X;z8Yy?5=h z$An0a<~}>Mdy1g{AiZa@#2|-LO3A1u$l$tT$Z3OHrnXzNnMlLxYMmh{Y$;Zxz-=v~ zOdli4|C;Pqz0x18xK1<4SGmhofwFP4)SAkQth_9Ta%>YNRa8FT*@8i{Y@Sn(3g;Xb%M2>fGF!+dpz~g{$RrjEH5R@Ee}3i$>0$MHbknU(SiW~PX93_OK z2icAm>zAaT*1bI?K)R=a$zyG>S&^f~R}U`^8HtfXuQ4HeAnzr^h2s}26gAT)Xd$`{ zmfjZdR`nC~Fc7nE?(Lky2J1yd0v1Z_@h9k)@R3Oj4$2nqlJ)f4F@5sSCAd8hC60?S zEDnIqJ}$wFmZYB*w8LHj5e|XS0vc|t2r{(WoalgqZ>M@=}q>BQ%sf?ZO6$EcFmEr%;p+9AW%D~~yp zJGv`)h^gTQHq!myXaxNl%~<=y9}%UizL&+c-l{Q=c>k>Scc>)m_q#;7*<*we@9ip^ zCPIw|v+qiTx8!R@MY>%5iBWwnI%>W3l_&k~Mj2_{%Fvg8l1wR?SHfgNhDRnNym;p~ zq|ssr(D~_4znjd=1y9s@#~M7W3%)h!-8Evn7zrKp_(jEyxc;S*sY%`sOG+-ia@_1%UIvz$#-kDOC2E{^qq+4DE)Ziw2Z1%yZxi+z`u4t~Sc z`py>wx0fPOq!GEqxls(b8hdV6vlIH)WBHv%;_EnX^ZplAef|V14+DmK9*oVsPB zBI!`>C-BTnDB>E{8L^i_`GSJz0F0m~>YNs_@?Oc>>>RBHwhWE>#m*jR4n9v|(|Kw-2KwKRxoO7~D4I?ir}TJEuQMyP!akBVxnIE3skxZBhY-8(L$P^u^n z4rKj~F0%eD_$@E%^q$U&d9TF}#A~Py=b%Am9l4a!IoAWdYQGxc^Xn?Ekh#OjRiQ&j zD7NcU>DkQT1EO&$Z#Q)TglpARyIgT9r*3K0W4W@%6@dttH=(-T zrUc^>gN7wzJM+A6d$ZNX07|jMY0j=9FX2FA-)B|N~ zeixk@{46mk+;=}16Qxnr5z9{}6SI&1Yn4CSHB3*aMTA$<#l;pERDSnIr$hdG*1(^0 z8~+AlYca?VPyf7(y0cb+;)_<3^N-ZNGZm92OL(yHUX`8nX&5tuhUll3!Q_6eiPi1#jP?!MD!ovwFZM~}QtQ0V80b_z{4GdLRX?uvNch{Ei1gn&{m%OUm zw8CEiXEF4(MWqtxQSEpbm<)!}OjdS3QUOx;fsjGjN|0=}lf|iZwItNj0K5BfLzM3BglhQTqLma^nLK>AH@m)xsu^{N8k?+B)_yV_4v+Fvl;?n$0C~x9|^i4V6*- z#A&!(dpReoE_AA!$LHMmP2oMh{FHKXx~Kci*eab#28(lsSSIjK*CzX%N*7YX-Qrx2 zhmq2#NHOrOsm#NObGXPPERh@qso_EJ)}p?wbJ5`yx0a$WU$28ocFxfUYM(k}dyOBG z9q%L)7h7anep%}0q)7Mu2BTlJ_$Z(3ai6@*FN~uLQDYqa@ECV;%&VPU4!*MXMLX=E zdzb7p;C3h8A!Ce~!)3%}smEKi>Ue~H^_s&vOd97I6 zrtvr7+Vjp^kNEbQ9j(G2eB` z&3r8dE13>VpH+}i^ay}CO;p5wM}3*#EtB4 z#~j*Trp2_G%r)iO-zdJ19noD#2@aaWhw-WTm&AgW7uPTytl*7xm}sUX}dU zV!Wp>0X$;djWu3$cSX$Q}_Ov&Ci&?3rht_&l*^_Nm$XR3?eB^8ee* z()B2^Pj~5V9v1Kf$I{((&CgFW4FgMU-}GvJ zLSc`|ZS zFL(Xt*w`-Ft@GA=7xqo3y1LY##QV>I-SkgN-Czp;a+k@qJ0Y$Z^Nc+IH=Kg6ghaUZ zcWcAcd3c!i-xy~6+33H>Qwzng>T3DH%9}SKkYDCzDuylIXH~3^scg>d8t>Yw2}ZY4 ze_gXYAk{IGl9U}LGONCV-IxpW4yWq8=;zotqA`}8dxzFA>K!~qNQuePzbMn9d+qx*Y&q%bf2^ARIZ^(^UhK@qg=!a zFIH+Aq>V#ely)2JTlv(;wH0H9$ESsN_;iFf!yxl};slq-_#(@hLV}vj7qESjNacIN zLB_>#7H?fR))0&wmekMDO}o-@+*|n3QuiOS-wJQt_YZVp#Xlo5bF}uZ|5hLvi|$nE zJASQ=36M|lrU}_c^?6y|Ai3_~%x^x!FHCL8Kj(Fee&%pwIi3+@;KApCQ(>`>IO~Sv zW*50ia-nXAHk}H*vlZNg^|hr=xcVnH%>M^LAu4F%eEYF0}cC zGrEs5eg35!TEH-wNL}0eRJABC&+?-F&8Ma@UiE(n6L#!l>LWQ8rClMzOE+VOPi8(N zG*cW(9!?OJR8AQ0O-o?+$uL&@>*y~~mfjyvX6?UqCam=j7w2rV$w$RwBVr)M)l<9N zX$j|OG;bXFT8o@gMn*1KJpeY$x_=yFUbREgGg|iJ7)5j>%X!U*U~^aHWgg$p=w%ck zZ`73^+gDHjy(zdNz7sTow^A4kPW6kW#AvhZ$#2Dzh|{DEJ;0wcfBZ$Bk^7^x=$<@% zVvM%zT*Y&m0y|x-IPe0jV3DB^R$g6TB&`ojl47(?_v9^-qor-YC^<&k1&jckHyD8~ z2#iu=v?IU>bn#%67NeaGMxe_DBk8I2e76 z(Vhn*(5-_}R*d%k96KEi%#JQzO^)R@Mx;@TWXL)JzcY%L8QG2xpZU?DIFly!o7k8{ zl<7QLi$HP|8AW7L1+TX)$@1ZEX_*OT+stFSjta)U&9)Jege)oH-4ld>u$QdzDe)(C z=MWh&p3v-a=ZJF&T2i9WYjj=Zyxf@>?~>Ito~@wQGL?T@a?~ep`i&>>Lp#m9nY?q4rKwMzkw$b%MMZFa-Z(sf0F=UBCk>rBwDV>hzDg0h?70+CDRbwo;Gf&h+eCHQ@v0vUvM0uTg$%+$hxF#sqAfsAFf74m=V{FTsF_@)yH zC+IPn@O1SIAs)z&=Nn`5R8%Kzcw1rL$4)bMZ13E9;GO`uXC07R<$Q@6D5ofj6pIZ| zWkJH$sUTqqp(D1)qxAgybiqeox+jS+Ff>LgQ4gvmi>lwHB8;o^IDUp zMX5IL-@V3PrWEf)@(SCW{IKe*=N<|1>1r7jTFwuuELJ8H^O!waYgHd>TzS*~L#?lOxGRGup9^U*W+r&{5MBZ`D@}-`=^6JIUw7S1==T%GE7R++Cp; z?e+x(k0h|lnNQ9}0JIEKR<8Hs^dQ=cq!GQnBWn*; zzkU18<>*Z5HQ|$ir!J^P@S@d_^NJ=Z->*w~aW<}~)os-%lVsGhIxc*J{p5|?zJUaF17$O&0GyR!bOVosp+|Gtu z1TlKpbv6e%qvyHywv4`bDJQ*|jya=`|7m@Lpk_y(!RtIGJ2A^#p}9OZfZ6TK)>gUx zY4WCOvaaZ8tQy7mESJ|&peOMNbyD%0wF=~@fuy4~jjb$yShkNLDjCV40CLQD;DCoa z>rL)$vsy7b#S^}uN@_Xcf2~Slf`Xq}d(<4hNmbl`(R8%Fqef-DH+ydRQ&Hi^>Jxm5 zVX8nI4y2HxM<^H~f6)lN-#WhT3H|27(p@baBA&zJ)WhAj#ThHKOn)yQvC+uSWQLIw;pLd!WkrYD5BOmx6bSsjl_ z8jOXDXwQ$dAwn*Vplw$~nYyUY?>Mg}^XznYy=W)PE3CsiO_bBYcE0gM^| zp2;Mh^LAJ!=SSXyz^;}iG{R&eD)pB;H0rYs5;?eOk>YE#m`EWxz=@+iF4L1Rxty2d zz==I@Vho&I00=r_5Kf;=4iyNiI3rU0o9#oSkQEnDkvhO2h!y`9`6#e!3wT!p5M&X( z7!eV`)TEnMo!?#}=gae0@wFG2NROB)0=xP_SZA(yOul*_#i<2|1G^#sLy{dS1Oni@ z>5<~^J^=7S5a)6=VBp7!Q!fXJuk{1)Hnuks8k`h?LHCRgAU^$jCq1<4eGOFVSNs%# zNG2b}*Y3+jd|w0*@QFRZf;MT?-{^h>z`%_C|+go!@`vzJ?w^q|*Uc zb@m76;7*=957qt~$GxuJfm?yOdK_%w3N<6B`A6qvzVle98&>2+*}0NA?&KW`un zv7So}36q0=F&R$=W3LRLgQm`t6_LLel*G!MzSUlz{kNJ}e-%Nx#cH>?Ek6alKKf4; zone*vL`?G>(~*kWHiM5dQE%VuIMnzrx0Jo}OKP6RQVFQF=fBHvGYc`QkG|dC<#XnE z#_KoxJKg`3`QH!>(;_2Mtonnp z-(T+O!zM}h6{nusd-iB+IXsYVBEE}?R+W6Xx3OP z%DLrcmhf<;R4vBP+VS}hRlm|++e^!%GwsT!obHufbwLfbVM{_j6@S*t8`>E)^IQ?2ds3`c)Y5JJC6tN6`Zg{-sU+$7ua1-mq0#WCukG1ip0Nm-SB0S!;_PY0hr zC1y4)W!du!$BTvT{HzN?g(Z?j(Hn=O;0LS^h@6h4FF&Mr3|M8GauFAIZ`UZW3NN!C;n57>N@yTX=(8zoKy+O`i>>D;K_)kW7Jo$c(U8JAW z0M|vxIrSxV;rsnR+MkTjAo;MYn;_@Z(TuK>*%8@<1$k{G5V-D)+vQ|#_(FsYz zf`s3VNv$vcO{V`+$UmQx9N#kjoubKz!C@t0UrUEWXskC%nh^Q{mtnNcx^<^6 z6}CYY+TnM->mK+I^IZZ)luGH3ot{8s`b4p?;PLP(?fO+0j(OSdv{QV7n$dZ*Qv&8s zDlz+>WxcBIc`ki6Yl@ZYV^{TfOtk*Zr(H^ea*0=8qNP#i06WBv&b#laEv||=?U)RZ zTGImQc^um`)$@M9{%Rh{6ZZNZgyUX2?H)wsUXAfyE#Y2m0H~2$0JY?MHMM)S5<9x) zfv4|r?Ja1>bnE3mS+kOck5ns%ShE@AOPnWLypVR)tC#$c0tg>KoZl0l?m4M|h`i_A z6A5$jAJQyXq%-E^&q-RSV=!ap6tl=%sIiM@v8rZbXw7EI8tZJ6$Vp39bgSaL3&|VY z^|Tk~d49&QZdp9kiz-Foy;Pca`)PtgkWU-0w>2#~51Z)D9Gqjk&~Dcs4Z4{~XQ znoQ-~T_k1PUAkiq%|jy&>6T*bHfbNu^}V94j^le|PMa6iCo7B5C)*X>C+m&gC%b+) zqP!_#PD89?PE$!57w%g|L!f6Z$55>*!|*SiJgrZdC`~>J^!g9ezy5Fc=D(f3ISmI!pEOcbpY*bBT=;nvEkRXrpEUfi zPx^E5tj@ZF9K*lrkILOMVal729=0gjfY3iY{Spqhk!JW8{85>1FwBU4>ybGP#7#pW zz)VGu=Ga|B2D@lGMG%{q6hR!c2nebGkH$yPw|CUWvY+;-2JD{~&0J4#X*~+d zI`;o4`VS)W+jN04u74X`r;oS)>R(gE_Hb!T_~rO(4MJ1#)AqW~o`*2io^dZ`zTEzH z4$UumA`;HvGu6MK6~hh4?KeJRWtOwA{j`P=D?|8Tfp56fTE0X7?TRp>_NP1lLVLlA z?&t5M_Luhll%(|mKl<7?P8+a0yUWx5qsYmd& zbla>v;ghdr;8UMYHR@bb?m*}uUUmz|SJu)mZ-@+?7Dk@xsDzF@)lj={G|DlI3r%%S zuxVdEawPu2;+b};ap)^Re%U5?7;v+1QuGVaz;Bn5;V@XDe(K2Iy!n$Xw3_YNPUV