From 45329fbdce716f356ccbab0ce5ad9df77fd7b3c4 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 8 Sep 2012 16:02:30 -0400 Subject: [PATCH 01/67] schedule: Allow arguments for scheduled functions, lock before modifying heap Signed-off-by: James McCoy --- src/schedule.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/schedule.py b/src/schedule.py index 677661201..7aa9ecfe6 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -34,6 +34,7 @@ Supybot driver. import time import heapq +from threading import Lock import supybot.log as log import supybot.world as world @@ -61,10 +62,12 @@ class Schedule(drivers.IrcDriver): self.schedule = [] self.events = {} self.counter = 0 + self.lock = Lock() def reset(self): - self.events.clear() - self.schedule[:] = [] + with self.lock: + self.events.clear() + self.schedule[:] = [] # We don't reset the counter here because if someone has held an id of # one of the nuked events, we don't want him removing new events with # his old id. @@ -72,7 +75,7 @@ class Schedule(drivers.IrcDriver): def name(self): return 'Schedule' - def addEvent(self, f, t, name=None): + def addEvent(self, f, t, name=None, args=[], kwargs={}): """Schedules an event f to run at time t. name must be hashable and not an int. @@ -82,20 +85,22 @@ class Schedule(drivers.IrcDriver): self.counter += 1 assert name not in self.events, \ 'An event with the same name has already been scheduled.' - self.events[name] = f - heapq.heappush(self.schedule, mytuple((t, name))) + with self.lock: + self.events[name] = f + heapq.heappush(self.schedule, mytuple((t, name, args, kwargs))) return name def removeEvent(self, name): """Removes the event with the given name from the schedule.""" f = self.events.pop(name) - self.schedule = [(t, n) for (t, n) in self.schedule if n != name] # We must heapify here because the heap property may not be preserved # by the above list comprehension. We could, conceivably, just mark # the elements of the heap as removed and ignore them when we heappop, # but that would only save a constant factor (we're already linear for # the listcomp) so I'm not worried about it right now. - heapq.heapify(self.schedule) + with self.lock: + self.schedule = [x for x in self.schedule if x[1] != name] + heapq.heapify(self.schedule) return f def rescheduleEvent(self, name, t): @@ -123,11 +128,12 @@ class Schedule(drivers.IrcDriver): 'why do we continue to live?') time.sleep(1) # We're the only driver; let's pause to think. while self.schedule and self.schedule[0][0] < time.time(): - (t, name) = heapq.heappop(self.schedule) - f = self.events[name] + with self.lock: + (t, name, args, kwargs) = heapq.heappop(self.schedule) + f = self.events[name] del self.events[name] try: - f() + f(*args, **kwargs) except Exception, e: log.exception('Uncaught exception in scheduled function:') From 748b76404fb09a1462b0819661f03b39f056c6e0 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 10 Sep 2012 20:07:43 -0400 Subject: [PATCH 02/67] Google: Use standard json module now that 2.6 is minimum Python version Signed-off-by: James McCoy --- plugins/Google/local/__init__.py | 1 - plugins/Google/local/simplejson/__init__.py | 318 -------------- plugins/Google/local/simplejson/decoder.py | 354 ---------------- plugins/Google/local/simplejson/encoder.py | 440 -------------------- plugins/Google/local/simplejson/scanner.py | 65 --- plugins/Google/local/simplejson/tool.py | 37 -- plugins/Google/plugin.py | 28 +- setup.py | 2 - 8 files changed, 4 insertions(+), 1241 deletions(-) delete mode 100644 plugins/Google/local/__init__.py delete mode 100644 plugins/Google/local/simplejson/__init__.py delete mode 100644 plugins/Google/local/simplejson/decoder.py delete mode 100644 plugins/Google/local/simplejson/encoder.py delete mode 100644 plugins/Google/local/simplejson/scanner.py delete mode 100644 plugins/Google/local/simplejson/tool.py diff --git a/plugins/Google/local/__init__.py b/plugins/Google/local/__init__.py deleted file mode 100644 index e86e97b86..000000000 --- a/plugins/Google/local/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Stub so local is a module, used for third-party modules diff --git a/plugins/Google/local/simplejson/__init__.py b/plugins/Google/local/simplejson/__init__.py deleted file mode 100644 index d5b4d3991..000000000 --- a/plugins/Google/local/simplejson/__init__.py +++ /dev/null @@ -1,318 +0,0 @@ -r"""JSON (JavaScript Object Notation) is a subset of -JavaScript syntax (ECMA-262 3rd edition) used as a lightweight data -interchange format. - -:mod:`simplejson` exposes an API familiar to users of the standard library -:mod:`marshal` and :mod:`pickle` modules. It is the externally maintained -version of the :mod:`json` library contained in Python 2.6, but maintains -compatibility with Python 2.4 and Python 2.5 and (currently) has -significant performance advantages, even without using the optional C -extension for speedups. - -Encoding basic Python object hierarchies:: - - >>> import simplejson as json - >>> json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}]) - '["foo", {"bar": ["baz", null, 1.0, 2]}]' - >>> print json.dumps("\"foo\bar") - "\"foo\bar" - >>> print json.dumps(u'\u1234') - "\u1234" - >>> print json.dumps('\\') - "\\" - >>> print json.dumps({"c": 0, "b": 0, "a": 0}, sort_keys=True) - {"a": 0, "b": 0, "c": 0} - >>> from StringIO import StringIO - >>> io = StringIO() - >>> json.dump(['streaming API'], io) - >>> io.getvalue() - '["streaming API"]' - -Compact encoding:: - - >>> import simplejson as json - >>> json.dumps([1,2,3,{'4': 5, '6': 7}], separators=(',',':')) - '[1,2,3,{"4":5,"6":7}]' - -Pretty printing:: - - >>> import simplejson as json - >>> s = json.dumps({'4': 5, '6': 7}, sort_keys=True, indent=4) - >>> print '\n'.join([l.rstrip() for l in s.splitlines()]) - { - "4": 5, - "6": 7 - } - -Decoding JSON:: - - >>> import simplejson as json - >>> obj = [u'foo', {u'bar': [u'baz', None, 1.0, 2]}] - >>> json.loads('["foo", {"bar":["baz", null, 1.0, 2]}]') == obj - True - >>> json.loads('"\\"foo\\bar"') == u'"foo\x08ar' - True - >>> from StringIO import StringIO - >>> io = StringIO('["streaming API"]') - >>> json.load(io)[0] == 'streaming API' - True - -Specializing JSON object decoding:: - - >>> import simplejson as json - >>> def as_complex(dct): - ... if '__complex__' in dct: - ... return complex(dct['real'], dct['imag']) - ... return dct - ... - >>> json.loads('{"__complex__": true, "real": 1, "imag": 2}', - ... object_hook=as_complex) - (1+2j) - >>> import decimal - >>> json.loads('1.1', parse_float=decimal.Decimal) == decimal.Decimal('1.1') - True - -Specializing JSON object encoding:: - - >>> import simplejson as json - >>> def encode_complex(obj): - ... if isinstance(obj, complex): - ... return [obj.real, obj.imag] - ... raise TypeError(repr(o) + " is not JSON serializable") - ... - >>> json.dumps(2 + 1j, default=encode_complex) - '[2.0, 1.0]' - >>> json.JSONEncoder(default=encode_complex).encode(2 + 1j) - '[2.0, 1.0]' - >>> ''.join(json.JSONEncoder(default=encode_complex).iterencode(2 + 1j)) - '[2.0, 1.0]' - - -Using simplejson.tool from the shell to validate and pretty-print:: - - $ echo '{"json":"obj"}' | python -m simplejson.tool - { - "json": "obj" - } - $ echo '{ 1.2:3.4}' | python -m simplejson.tool - Expecting property name: line 1 column 2 (char 2) -""" -__version__ = '2.0.9' -__all__ = [ - 'dump', 'dumps', 'load', 'loads', - 'JSONDecoder', 'JSONEncoder', -] - -__author__ = 'Bob Ippolito ' - -from decoder import JSONDecoder -from encoder import JSONEncoder - -_default_encoder = JSONEncoder( - skipkeys=False, - ensure_ascii=True, - check_circular=True, - allow_nan=True, - indent=None, - separators=None, - encoding='utf-8', - default=None, -) - -def dump(obj, fp, skipkeys=False, ensure_ascii=True, check_circular=True, - allow_nan=True, cls=None, indent=None, separators=None, - encoding='utf-8', default=None, **kw): - """Serialize ``obj`` as a JSON formatted stream to ``fp`` (a - ``.write()``-supporting file-like object). - - If ``skipkeys`` is true then ``dict`` keys that are not basic types - (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``) - will be skipped instead of raising a ``TypeError``. - - If ``ensure_ascii`` is false, then the some chunks written to ``fp`` - may be ``unicode`` instances, subject to normal Python ``str`` to - ``unicode`` coercion rules. Unless ``fp.write()`` explicitly - understands ``unicode`` (as in ``codecs.getwriter()``) this is likely - to cause an error. - - If ``check_circular`` is false, then the circular reference check - for container types will be skipped and a circular reference will - result in an ``OverflowError`` (or worse). - - If ``allow_nan`` is false, then it will be a ``ValueError`` to - serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``) - in strict compliance of the JSON specification, instead of using the - JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``). - - If ``indent`` is a non-negative integer, then JSON array elements and object - members will be pretty-printed with that indent level. An indent level - of 0 will only insert newlines. ``None`` is the most compact representation. - - If ``separators`` is an ``(item_separator, dict_separator)`` tuple - then it will be used instead of the default ``(', ', ': ')`` separators. - ``(',', ':')`` is the most compact JSON representation. - - ``encoding`` is the character encoding for str instances, default is UTF-8. - - ``default(obj)`` is a function that should return a serializable version - of obj or raise TypeError. The default simply raises TypeError. - - To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the - ``.default()`` method to serialize additional types), specify it with - the ``cls`` kwarg. - - """ - # cached encoder - if (not skipkeys and ensure_ascii and - check_circular and allow_nan and - cls is None and indent is None and separators is None and - encoding == 'utf-8' and default is None and not kw): - iterable = _default_encoder.iterencode(obj) - else: - if cls is None: - cls = JSONEncoder - iterable = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii, - check_circular=check_circular, allow_nan=allow_nan, indent=indent, - separators=separators, encoding=encoding, - default=default, **kw).iterencode(obj) - # could accelerate with writelines in some versions of Python, at - # a debuggability cost - for chunk in iterable: - fp.write(chunk) - - -def dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True, - allow_nan=True, cls=None, indent=None, separators=None, - encoding='utf-8', default=None, **kw): - """Serialize ``obj`` to a JSON formatted ``str``. - - If ``skipkeys`` is false then ``dict`` keys that are not basic types - (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``) - will be skipped instead of raising a ``TypeError``. - - If ``ensure_ascii`` is false, then the return value will be a - ``unicode`` instance subject to normal Python ``str`` to ``unicode`` - coercion rules instead of being escaped to an ASCII ``str``. - - If ``check_circular`` is false, then the circular reference check - for container types will be skipped and a circular reference will - result in an ``OverflowError`` (or worse). - - If ``allow_nan`` is false, then it will be a ``ValueError`` to - serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``) in - strict compliance of the JSON specification, instead of using the - JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``). - - If ``indent`` is a non-negative integer, then JSON array elements and - object members will be pretty-printed with that indent level. An indent - level of 0 will only insert newlines. ``None`` is the most compact - representation. - - If ``separators`` is an ``(item_separator, dict_separator)`` tuple - then it will be used instead of the default ``(', ', ': ')`` separators. - ``(',', ':')`` is the most compact JSON representation. - - ``encoding`` is the character encoding for str instances, default is UTF-8. - - ``default(obj)`` is a function that should return a serializable version - of obj or raise TypeError. The default simply raises TypeError. - - To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the - ``.default()`` method to serialize additional types), specify it with - the ``cls`` kwarg. - - """ - # cached encoder - if (not skipkeys and ensure_ascii and - check_circular and allow_nan and - cls is None and indent is None and separators is None and - encoding == 'utf-8' and default is None and not kw): - return _default_encoder.encode(obj) - if cls is None: - cls = JSONEncoder - return cls( - skipkeys=skipkeys, ensure_ascii=ensure_ascii, - check_circular=check_circular, allow_nan=allow_nan, indent=indent, - separators=separators, encoding=encoding, default=default, - **kw).encode(obj) - - -_default_decoder = JSONDecoder(encoding=None, object_hook=None) - - -def load(fp, encoding=None, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, **kw): - """Deserialize ``fp`` (a ``.read()``-supporting file-like object containing - a JSON document) to a Python object. - - If the contents of ``fp`` is encoded with an ASCII based encoding other - than utf-8 (e.g. latin-1), then an appropriate ``encoding`` name must - be specified. Encodings that are not ASCII based (such as UCS-2) are - not allowed, and should be wrapped with - ``codecs.getreader(fp)(encoding)``, or simply decoded to a ``unicode`` - object and passed to ``loads()`` - - ``object_hook`` is an optional function that will be called with the - result of any object literal decode (a ``dict``). The return value of - ``object_hook`` will be used instead of the ``dict``. This feature - can be used to implement custom decoders (e.g. JSON-RPC class hinting). - - To use a custom ``JSONDecoder`` subclass, specify it with the ``cls`` - kwarg. - - """ - return loads(fp.read(), - encoding=encoding, cls=cls, object_hook=object_hook, - parse_float=parse_float, parse_int=parse_int, - parse_constant=parse_constant, **kw) - - -def loads(s, encoding=None, cls=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, **kw): - """Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON - document) to a Python object. - - If ``s`` is a ``str`` instance and is encoded with an ASCII based encoding - other than utf-8 (e.g. latin-1) then an appropriate ``encoding`` name - must be specified. Encodings that are not ASCII based (such as UCS-2) - are not allowed and should be decoded to ``unicode`` first. - - ``object_hook`` is an optional function that will be called with the - result of any object literal decode (a ``dict``). The return value of - ``object_hook`` will be used instead of the ``dict``. This feature - can be used to implement custom decoders (e.g. JSON-RPC class hinting). - - ``parse_float``, if specified, will be called with the string - of every JSON float to be decoded. By default this is equivalent to - float(num_str). This can be used to use another datatype or parser - for JSON floats (e.g. decimal.Decimal). - - ``parse_int``, if specified, will be called with the string - of every JSON int to be decoded. By default this is equivalent to - int(num_str). This can be used to use another datatype or parser - for JSON integers (e.g. float). - - ``parse_constant``, if specified, will be called with one of the - following strings: -Infinity, Infinity, NaN, null, true, false. - This can be used to raise an exception if invalid JSON numbers - are encountered. - - To use a custom ``JSONDecoder`` subclass, specify it with the ``cls`` - kwarg. - - """ - if (cls is None and encoding is None and object_hook is None and - parse_int is None and parse_float is None and - parse_constant is None and not kw): - return _default_decoder.decode(s) - if cls is None: - cls = JSONDecoder - if object_hook is not None: - kw['object_hook'] = object_hook - if parse_float is not None: - kw['parse_float'] = parse_float - if parse_int is not None: - kw['parse_int'] = parse_int - if parse_constant is not None: - kw['parse_constant'] = parse_constant - return cls(encoding=encoding, **kw).decode(s) diff --git a/plugins/Google/local/simplejson/decoder.py b/plugins/Google/local/simplejson/decoder.py deleted file mode 100644 index 811e73347..000000000 --- a/plugins/Google/local/simplejson/decoder.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Implementation of JSONDecoder -""" -import re -import sys -import struct - -from scanner import make_scanner -try: - from _speedups import scanstring as c_scanstring -except ImportError: - c_scanstring = None - -__all__ = ['JSONDecoder'] - -FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL - -def _floatconstants(): - _BYTES = '7FF80000000000007FF0000000000000'.decode('hex') - if sys.byteorder != 'big': - _BYTES = _BYTES[:8][::-1] + _BYTES[8:][::-1] - nan, inf = struct.unpack('dd', _BYTES) - return nan, inf, -inf - -NaN, PosInf, NegInf = _floatconstants() - - -def linecol(doc, pos): - lineno = doc.count('\n', 0, pos) + 1 - if lineno == 1: - colno = pos - else: - colno = pos - doc.rindex('\n', 0, pos) - return lineno, colno - - -def errmsg(msg, doc, pos, end=None): - # Note that this function is called from _speedups - lineno, colno = linecol(doc, pos) - if end is None: - #fmt = '{0}: line {1} column {2} (char {3})' - #return fmt.format(msg, lineno, colno, pos) - fmt = '%s: line %d column %d (char %d)' - return fmt % (msg, lineno, colno, pos) - endlineno, endcolno = linecol(doc, end) - #fmt = '{0}: line {1} column {2} - line {3} column {4} (char {5} - {6})' - #return fmt.format(msg, lineno, colno, endlineno, endcolno, pos, end) - fmt = '%s: line %d column %d - line %d column %d (char %d - %d)' - return fmt % (msg, lineno, colno, endlineno, endcolno, pos, end) - - -_CONSTANTS = { - '-Infinity': NegInf, - 'Infinity': PosInf, - 'NaN': NaN, -} - -STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS) -BACKSLASH = { - '"': u'"', '\\': u'\\', '/': u'/', - 'b': u'\b', 'f': u'\f', 'n': u'\n', 'r': u'\r', 't': u'\t', -} - -DEFAULT_ENCODING = "utf-8" - -def py_scanstring(s, end, encoding=None, strict=True, _b=BACKSLASH, _m=STRINGCHUNK.match): - """Scan the string s for a JSON string. End is the index of the - character in s after the quote that started the JSON string. - Unescapes all valid JSON string escape sequences and raises ValueError - on attempt to decode an invalid string. If strict is False then literal - control characters are allowed in the string. - - Returns a tuple of the decoded string and the index of the character in s - after the end quote.""" - if encoding is None: - encoding = DEFAULT_ENCODING - chunks = [] - _append = chunks.append - begin = end - 1 - while 1: - chunk = _m(s, end) - if chunk is None: - raise ValueError( - errmsg("Unterminated string starting at", s, begin)) - end = chunk.end() - content, terminator = chunk.groups() - # Content is contains zero or more unescaped string characters - if content: - if not isinstance(content, unicode): - content = unicode(content, encoding) - _append(content) - # Terminator is the end of string, a literal control character, - # or a backslash denoting that an escape sequence follows - if terminator == '"': - break - elif terminator != '\\': - if strict: - msg = "Invalid control character %r at" % (terminator,) - #msg = "Invalid control character {0!r} at".format(terminator) - raise ValueError(errmsg(msg, s, end)) - else: - _append(terminator) - continue - try: - esc = s[end] - except IndexError: - raise ValueError( - errmsg("Unterminated string starting at", s, begin)) - # If not a unicode escape sequence, must be in the lookup table - if esc != 'u': - try: - char = _b[esc] - except KeyError: - msg = "Invalid \\escape: " + repr(esc) - raise ValueError(errmsg(msg, s, end)) - end += 1 - else: - # Unicode escape sequence - esc = s[end + 1:end + 5] - next_end = end + 5 - if len(esc) != 4: - msg = "Invalid \\uXXXX escape" - raise ValueError(errmsg(msg, s, end)) - uni = int(esc, 16) - # Check for surrogate pair on UCS-4 systems - if 0xd800 <= uni <= 0xdbff and sys.maxunicode > 65535: - msg = "Invalid \\uXXXX\\uXXXX surrogate pair" - if not s[end + 5:end + 7] == '\\u': - raise ValueError(errmsg(msg, s, end)) - esc2 = s[end + 7:end + 11] - if len(esc2) != 4: - raise ValueError(errmsg(msg, s, end)) - uni2 = int(esc2, 16) - uni = 0x10000 + (((uni - 0xd800) << 10) | (uni2 - 0xdc00)) - next_end += 6 - char = unichr(uni) - end = next_end - # Append the unescaped character - _append(char) - return u''.join(chunks), end - - -# Use speedup if available -scanstring = c_scanstring or py_scanstring - -WHITESPACE = re.compile(r'[ \t\n\r]*', FLAGS) -WHITESPACE_STR = ' \t\n\r' - -def JSONObject((s, end), encoding, strict, scan_once, object_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR): - pairs = {} - # Use a slice to prevent IndexError from being raised, the following - # check will raise a more specific ValueError if the string is empty - nextchar = s[end:end + 1] - # Normally we expect nextchar == '"' - if nextchar != '"': - if nextchar in _ws: - end = _w(s, end).end() - nextchar = s[end:end + 1] - # Trivial empty object - if nextchar == '}': - return pairs, end + 1 - elif nextchar != '"': - raise ValueError(errmsg("Expecting property name", s, end)) - end += 1 - while True: - key, end = scanstring(s, end, encoding, strict) - - # To skip some function call overhead we optimize the fast paths where - # the JSON key separator is ": " or just ":". - if s[end:end + 1] != ':': - end = _w(s, end).end() - if s[end:end + 1] != ':': - raise ValueError(errmsg("Expecting : delimiter", s, end)) - - end += 1 - - try: - if s[end] in _ws: - end += 1 - if s[end] in _ws: - end = _w(s, end + 1).end() - except IndexError: - pass - - try: - value, end = scan_once(s, end) - except StopIteration: - raise ValueError(errmsg("Expecting object", s, end)) - pairs[key] = value - - try: - nextchar = s[end] - if nextchar in _ws: - end = _w(s, end + 1).end() - nextchar = s[end] - except IndexError: - nextchar = '' - end += 1 - - if nextchar == '}': - break - elif nextchar != ',': - raise ValueError(errmsg("Expecting , delimiter", s, end - 1)) - - try: - nextchar = s[end] - if nextchar in _ws: - end += 1 - nextchar = s[end] - if nextchar in _ws: - end = _w(s, end + 1).end() - nextchar = s[end] - except IndexError: - nextchar = '' - - end += 1 - if nextchar != '"': - raise ValueError(errmsg("Expecting property name", s, end - 1)) - - if object_hook is not None: - pairs = object_hook(pairs) - return pairs, end - -def JSONArray((s, end), scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR): - values = [] - nextchar = s[end:end + 1] - if nextchar in _ws: - end = _w(s, end + 1).end() - nextchar = s[end:end + 1] - # Look-ahead for trivial empty array - if nextchar == ']': - return values, end + 1 - _append = values.append - while True: - try: - value, end = scan_once(s, end) - except StopIteration: - raise ValueError(errmsg("Expecting object", s, end)) - _append(value) - nextchar = s[end:end + 1] - if nextchar in _ws: - end = _w(s, end + 1).end() - nextchar = s[end:end + 1] - end += 1 - if nextchar == ']': - break - elif nextchar != ',': - raise ValueError(errmsg("Expecting , delimiter", s, end)) - - try: - if s[end] in _ws: - end += 1 - if s[end] in _ws: - end = _w(s, end + 1).end() - except IndexError: - pass - - return values, end - -class JSONDecoder(object): - """Simple JSON decoder - - Performs the following translations in decoding by default: - - +---------------+-------------------+ - | JSON | Python | - +===============+===================+ - | object | dict | - +---------------+-------------------+ - | array | list | - +---------------+-------------------+ - | string | unicode | - +---------------+-------------------+ - | number (int) | int, long | - +---------------+-------------------+ - | number (real) | float | - +---------------+-------------------+ - | true | True | - +---------------+-------------------+ - | false | False | - +---------------+-------------------+ - | null | None | - +---------------+-------------------+ - - It also understands ``NaN``, ``Infinity``, and ``-Infinity`` as - their corresponding ``float`` values, which is outside the JSON spec. - - """ - - def __init__(self, encoding=None, object_hook=None, parse_float=None, - parse_int=None, parse_constant=None, strict=True): - """``encoding`` determines the encoding used to interpret any ``str`` - objects decoded by this instance (utf-8 by default). It has no - effect when decoding ``unicode`` objects. - - Note that currently only encodings that are a superset of ASCII work, - strings of other encodings should be passed in as ``unicode``. - - ``object_hook``, if specified, will be called with the result - of every JSON object decoded and its return value will be used in - place of the given ``dict``. This can be used to provide custom - deserializations (e.g. to support JSON-RPC class hinting). - - ``parse_float``, if specified, will be called with the string - of every JSON float to be decoded. By default this is equivalent to - float(num_str). This can be used to use another datatype or parser - for JSON floats (e.g. decimal.Decimal). - - ``parse_int``, if specified, will be called with the string - of every JSON int to be decoded. By default this is equivalent to - int(num_str). This can be used to use another datatype or parser - for JSON integers (e.g. float). - - ``parse_constant``, if specified, will be called with one of the - following strings: -Infinity, Infinity, NaN. - This can be used to raise an exception if invalid JSON numbers - are encountered. - - """ - self.encoding = encoding - self.object_hook = object_hook - self.parse_float = parse_float or float - self.parse_int = parse_int or int - self.parse_constant = parse_constant or _CONSTANTS.__getitem__ - self.strict = strict - self.parse_object = JSONObject - self.parse_array = JSONArray - self.parse_string = scanstring - self.scan_once = make_scanner(self) - - def decode(self, s, _w=WHITESPACE.match): - """Return the Python representation of ``s`` (a ``str`` or ``unicode`` - instance containing a JSON document) - - """ - obj, end = self.raw_decode(s, idx=_w(s, 0).end()) - end = _w(s, end).end() - if end != len(s): - raise ValueError(errmsg("Extra data", s, end, len(s))) - return obj - - def raw_decode(self, s, idx=0): - """Decode a JSON document from ``s`` (a ``str`` or ``unicode`` beginning - with a JSON document) and return a 2-tuple of the Python - representation and the index in ``s`` where the document ended. - - This can be used to decode a JSON document from a string that may - have extraneous data at the end. - - """ - try: - obj, end = self.scan_once(s, idx) - except StopIteration: - raise ValueError("No JSON object could be decoded") - return obj, end diff --git a/plugins/Google/local/simplejson/encoder.py b/plugins/Google/local/simplejson/encoder.py deleted file mode 100644 index afff9e825..000000000 --- a/plugins/Google/local/simplejson/encoder.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Implementation of JSONEncoder -""" -import re - -try: - from _speedups import encode_basestring_ascii as c_encode_basestring_ascii -except ImportError: - c_encode_basestring_ascii = None -try: - from _speedups import make_encoder as c_make_encoder -except ImportError: - c_make_encoder = None - -ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]') -ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])') -HAS_UTF8 = re.compile(r'[\x80-\xff]') -ESCAPE_DCT = { - '\\': '\\\\', - '"': '\\"', - '\b': '\\b', - '\f': '\\f', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', -} -for i in range(0x20): - #ESCAPE_DCT.setdefault(chr(i), '\\u{0:04x}'.format(i)) - ESCAPE_DCT.setdefault(chr(i), '\\u%04x' % (i,)) - -# Assume this produces an infinity on all machines (probably not guaranteed) -INFINITY = float('1e66666') -FLOAT_REPR = repr - -def encode_basestring(s): - """Return a JSON representation of a Python string - - """ - def replace(match): - return ESCAPE_DCT[match.group(0)] - return '"' + ESCAPE.sub(replace, s) + '"' - - -def py_encode_basestring_ascii(s): - """Return an ASCII-only JSON representation of a Python string - - """ - if isinstance(s, str) and HAS_UTF8.search(s) is not None: - s = s.decode('utf-8') - def replace(match): - s = match.group(0) - try: - return ESCAPE_DCT[s] - except KeyError: - n = ord(s) - if n < 0x10000: - #return '\\u{0:04x}'.format(n) - return '\\u%04x' % (n,) - else: - # surrogate pair - n -= 0x10000 - s1 = 0xd800 | ((n >> 10) & 0x3ff) - s2 = 0xdc00 | (n & 0x3ff) - #return '\\u{0:04x}\\u{1:04x}'.format(s1, s2) - return '\\u%04x\\u%04x' % (s1, s2) - return '"' + str(ESCAPE_ASCII.sub(replace, s)) + '"' - - -encode_basestring_ascii = c_encode_basestring_ascii or py_encode_basestring_ascii - -class JSONEncoder(object): - """Extensible JSON encoder for Python data structures. - - Supports the following objects and types by default: - - +-------------------+---------------+ - | Python | JSON | - +===================+===============+ - | dict | object | - +-------------------+---------------+ - | list, tuple | array | - +-------------------+---------------+ - | str, unicode | string | - +-------------------+---------------+ - | int, long, float | number | - +-------------------+---------------+ - | True | true | - +-------------------+---------------+ - | False | false | - +-------------------+---------------+ - | None | null | - +-------------------+---------------+ - - To extend this to recognize other objects, subclass and implement a - ``.default()`` method with another method that returns a serializable - object for ``o`` if possible, otherwise it should call the superclass - implementation (to raise ``TypeError``). - - """ - item_separator = ', ' - key_separator = ': ' - def __init__(self, skipkeys=False, ensure_ascii=True, - check_circular=True, allow_nan=True, sort_keys=False, - indent=None, separators=None, encoding='utf-8', default=None): - """Constructor for JSONEncoder, with sensible defaults. - - If skipkeys is false, then it is a TypeError to attempt - encoding of keys that are not str, int, long, float or None. If - skipkeys is True, such items are simply skipped. - - If ensure_ascii is true, the output is guaranteed to be str - objects with all incoming unicode characters escaped. If - ensure_ascii is false, the output will be unicode object. - - If check_circular is true, then lists, dicts, and custom encoded - objects will be checked for circular references during encoding to - prevent an infinite recursion (which would cause an OverflowError). - Otherwise, no such check takes place. - - If allow_nan is true, then NaN, Infinity, and -Infinity will be - encoded as such. This behavior is not JSON specification compliant, - but is consistent with most JavaScript based encoders and decoders. - Otherwise, it will be a ValueError to encode such floats. - - If sort_keys is true, then the output of dictionaries will be - sorted by key; this is useful for regression tests to ensure - that JSON serializations can be compared on a day-to-day basis. - - If indent is a non-negative integer, then JSON array - elements and object members will be pretty-printed with that - indent level. An indent level of 0 will only insert newlines. - None is the most compact representation. - - If specified, separators should be a (item_separator, key_separator) - tuple. The default is (', ', ': '). To get the most compact JSON - representation you should specify (',', ':') to eliminate whitespace. - - If specified, default is a function that gets called for objects - that can't otherwise be serialized. It should return a JSON encodable - version of the object or raise a ``TypeError``. - - If encoding is not None, then all input strings will be - transformed into unicode using that encoding prior to JSON-encoding. - The default is UTF-8. - - """ - - self.skipkeys = skipkeys - self.ensure_ascii = ensure_ascii - self.check_circular = check_circular - self.allow_nan = allow_nan - self.sort_keys = sort_keys - self.indent = indent - if separators is not None: - self.item_separator, self.key_separator = separators - if default is not None: - self.default = default - self.encoding = encoding - - def default(self, o): - """Implement this method in a subclass such that it returns - a serializable object for ``o``, or calls the base implementation - (to raise a ``TypeError``). - - For example, to support arbitrary iterators, you could - implement default like this:: - - def default(self, o): - try: - iterable = iter(o) - except TypeError: - pass - else: - return list(iterable) - return JSONEncoder.default(self, o) - - """ - raise TypeError(repr(o) + " is not JSON serializable") - - def encode(self, o): - """Return a JSON string representation of a Python data structure. - - >>> JSONEncoder().encode({"foo": ["bar", "baz"]}) - '{"foo": ["bar", "baz"]}' - - """ - # This is for extremely simple cases and benchmarks. - if isinstance(o, basestring): - if isinstance(o, str): - _encoding = self.encoding - if (_encoding is not None - and not (_encoding == 'utf-8')): - o = o.decode(_encoding) - if self.ensure_ascii: - return encode_basestring_ascii(o) - else: - return encode_basestring(o) - # This doesn't pass the iterator directly to ''.join() because the - # exceptions aren't as detailed. The list call should be roughly - # equivalent to the PySequence_Fast that ''.join() would do. - chunks = self.iterencode(o, _one_shot=True) - if not isinstance(chunks, (list, tuple)): - chunks = list(chunks) - return ''.join(chunks) - - def iterencode(self, o, _one_shot=False): - """Encode the given object and yield each string - representation as available. - - For example:: - - for chunk in JSONEncoder().iterencode(bigobject): - mysocket.write(chunk) - - """ - if self.check_circular: - markers = {} - else: - markers = None - if self.ensure_ascii: - _encoder = encode_basestring_ascii - else: - _encoder = encode_basestring - if self.encoding != 'utf-8': - def _encoder(o, _orig_encoder=_encoder, _encoding=self.encoding): - if isinstance(o, str): - o = o.decode(_encoding) - return _orig_encoder(o) - - def floatstr(o, allow_nan=self.allow_nan, _repr=FLOAT_REPR, _inf=INFINITY, _neginf=-INFINITY): - # Check for specials. Note that this type of test is processor- and/or - # platform-specific, so do tests which don't depend on the internals. - - if o != o: - text = 'NaN' - elif o == _inf: - text = 'Infinity' - elif o == _neginf: - text = '-Infinity' - else: - return _repr(o) - - if not allow_nan: - raise ValueError( - "Out of range float values are not JSON compliant: " + - repr(o)) - - return text - - - if _one_shot and c_make_encoder is not None and not self.indent and not self.sort_keys: - _iterencode = c_make_encoder( - markers, self.default, _encoder, self.indent, - self.key_separator, self.item_separator, self.sort_keys, - self.skipkeys, self.allow_nan) - else: - _iterencode = _make_iterencode( - markers, self.default, _encoder, self.indent, floatstr, - self.key_separator, self.item_separator, self.sort_keys, - self.skipkeys, _one_shot) - return _iterencode(o, 0) - -def _make_iterencode(markers, _default, _encoder, _indent, _floatstr, _key_separator, _item_separator, _sort_keys, _skipkeys, _one_shot, - ## HACK: hand-optimized bytecode; turn globals into locals - False=False, - True=True, - ValueError=ValueError, - basestring=basestring, - dict=dict, - float=float, - id=id, - int=int, - isinstance=isinstance, - list=list, - long=long, - str=str, - tuple=tuple, - ): - - def _iterencode_list(lst, _current_indent_level): - if not lst: - yield '[]' - return - if markers is not None: - markerid = id(lst) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = lst - buf = '[' - if _indent is not None: - _current_indent_level += 1 - newline_indent = '\n' + (' ' * (_indent * _current_indent_level)) - separator = _item_separator + newline_indent - buf += newline_indent - else: - newline_indent = None - separator = _item_separator - first = True - for value in lst: - if first: - first = False - else: - buf = separator - if isinstance(value, basestring): - yield buf + _encoder(value) - elif value is None: - yield buf + 'null' - elif value is True: - yield buf + 'true' - elif value is False: - yield buf + 'false' - elif isinstance(value, (int, long)): - yield buf + str(value) - elif isinstance(value, float): - yield buf + _floatstr(value) - else: - yield buf - if isinstance(value, (list, tuple)): - chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, dict): - chunks = _iterencode_dict(value, _current_indent_level) - else: - chunks = _iterencode(value, _current_indent_level) - for chunk in chunks: - yield chunk - if newline_indent is not None: - _current_indent_level -= 1 - yield '\n' + (' ' * (_indent * _current_indent_level)) - yield ']' - if markers is not None: - del markers[markerid] - - def _iterencode_dict(dct, _current_indent_level): - if not dct: - yield '{}' - return - if markers is not None: - markerid = id(dct) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = dct - yield '{' - if _indent is not None: - _current_indent_level += 1 - newline_indent = '\n' + (' ' * (_indent * _current_indent_level)) - item_separator = _item_separator + newline_indent - yield newline_indent - else: - newline_indent = None - item_separator = _item_separator - first = True - if _sort_keys: - items = dct.items() - items.sort(key=lambda kv: kv[0]) - else: - items = dct.iteritems() - for key, value in items: - if isinstance(key, basestring): - pass - # JavaScript is weakly typed for these, so it makes sense to - # also allow them. Many encoders seem to do something like this. - elif isinstance(key, float): - key = _floatstr(key) - elif key is True: - key = 'true' - elif key is False: - key = 'false' - elif key is None: - key = 'null' - elif isinstance(key, (int, long)): - key = str(key) - elif _skipkeys: - continue - else: - raise TypeError("key " + repr(key) + " is not a string") - if first: - first = False - else: - yield item_separator - yield _encoder(key) - yield _key_separator - if isinstance(value, basestring): - yield _encoder(value) - elif value is None: - yield 'null' - elif value is True: - yield 'true' - elif value is False: - yield 'false' - elif isinstance(value, (int, long)): - yield str(value) - elif isinstance(value, float): - yield _floatstr(value) - else: - if isinstance(value, (list, tuple)): - chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, dict): - chunks = _iterencode_dict(value, _current_indent_level) - else: - chunks = _iterencode(value, _current_indent_level) - for chunk in chunks: - yield chunk - if newline_indent is not None: - _current_indent_level -= 1 - yield '\n' + (' ' * (_indent * _current_indent_level)) - yield '}' - if markers is not None: - del markers[markerid] - - def _iterencode(o, _current_indent_level): - if isinstance(o, basestring): - yield _encoder(o) - elif o is None: - yield 'null' - elif o is True: - yield 'true' - elif o is False: - yield 'false' - elif isinstance(o, (int, long)): - yield str(o) - elif isinstance(o, float): - yield _floatstr(o) - elif isinstance(o, (list, tuple)): - for chunk in _iterencode_list(o, _current_indent_level): - yield chunk - elif isinstance(o, dict): - for chunk in _iterencode_dict(o, _current_indent_level): - yield chunk - else: - if markers is not None: - markerid = id(o) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = o - o = _default(o) - for chunk in _iterencode(o, _current_indent_level): - yield chunk - if markers is not None: - del markers[markerid] - - return _iterencode diff --git a/plugins/Google/local/simplejson/scanner.py b/plugins/Google/local/simplejson/scanner.py deleted file mode 100644 index c70629ff4..000000000 --- a/plugins/Google/local/simplejson/scanner.py +++ /dev/null @@ -1,65 +0,0 @@ -"""JSON token scanner -""" -import re -try: - from _speedups import make_scanner as c_make_scanner -except ImportError: - c_make_scanner = None - -__all__ = ['make_scanner'] - -NUMBER_RE = re.compile( - r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?', - (re.VERBOSE | re.MULTILINE | re.DOTALL)) - -def py_make_scanner(context): - parse_object = context.parse_object - parse_array = context.parse_array - parse_string = context.parse_string - match_number = NUMBER_RE.match - encoding = context.encoding - strict = context.strict - parse_float = context.parse_float - parse_int = context.parse_int - parse_constant = context.parse_constant - object_hook = context.object_hook - - def _scan_once(string, idx): - try: - nextchar = string[idx] - except IndexError: - raise StopIteration - - if nextchar == '"': - return parse_string(string, idx + 1, encoding, strict) - elif nextchar == '{': - return parse_object((string, idx + 1), encoding, strict, _scan_once, object_hook) - elif nextchar == '[': - return parse_array((string, idx + 1), _scan_once) - elif nextchar == 'n' and string[idx:idx + 4] == 'null': - return None, idx + 4 - elif nextchar == 't' and string[idx:idx + 4] == 'true': - return True, idx + 4 - elif nextchar == 'f' and string[idx:idx + 5] == 'false': - return False, idx + 5 - - m = match_number(string, idx) - if m is not None: - integer, frac, exp = m.groups() - if frac or exp: - res = parse_float(integer + (frac or '') + (exp or '')) - else: - res = parse_int(integer) - return res, m.end() - elif nextchar == 'N' and string[idx:idx + 3] == 'NaN': - return parse_constant('NaN'), idx + 3 - elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity': - return parse_constant('Infinity'), idx + 8 - elif nextchar == '-' and string[idx:idx + 9] == '-Infinity': - return parse_constant('-Infinity'), idx + 9 - else: - raise StopIteration - - return _scan_once - -make_scanner = c_make_scanner or py_make_scanner diff --git a/plugins/Google/local/simplejson/tool.py b/plugins/Google/local/simplejson/tool.py deleted file mode 100644 index 90443317b..000000000 --- a/plugins/Google/local/simplejson/tool.py +++ /dev/null @@ -1,37 +0,0 @@ -r"""Command-line tool to validate and pretty-print JSON - -Usage:: - - $ echo '{"json":"obj"}' | python -m simplejson.tool - { - "json": "obj" - } - $ echo '{ 1.2:3.4}' | python -m simplejson.tool - Expecting property name: line 1 column 2 (char 2) - -""" -import sys -import simplejson - -def main(): - if len(sys.argv) == 1: - infile = sys.stdin - outfile = sys.stdout - elif len(sys.argv) == 2: - infile = open(sys.argv[1], 'rb') - outfile = sys.stdout - elif len(sys.argv) == 3: - infile = open(sys.argv[1], 'rb') - outfile = open(sys.argv[2], 'wb') - else: - raise SystemExit(sys.argv[0] + " [infile [outfile]]") - try: - obj = simplejson.load(infile) - except ValueError, e: - raise SystemExit(e) - simplejson.dump(obj, outfile, sort_keys=True, indent=4) - outfile.write('\n') - - -if __name__ == '__main__': - main() diff --git a/plugins/Google/plugin.py b/plugins/Google/plugin.py index 0d87ab8a3..bf0f53030 100644 --- a/plugins/Google/plugin.py +++ b/plugins/Google/plugin.py @@ -30,6 +30,7 @@ import re import cgi +import json import time import socket import urllib @@ -42,27 +43,6 @@ import supybot.ircmsgs as ircmsgs import supybot.ircutils as ircutils import supybot.callbacks as callbacks -simplejson = None - -try: - simplejson = utils.python.universalImport('json') -except ImportError: - pass - -try: - # The 3rd party simplejson module was included in Python 2.6 and renamed to - # json. Unfortunately, this conflicts with the 3rd party json module. - # Luckily, the 3rd party json module has a different interface so we test - # to make sure we aren't using it. - if simplejson is None or hasattr(simplejson, 'read'): - simplejson = utils.python.universalImport('simplejson', - 'local.simplejson') -except ImportError: - raise callbacks.Error, \ - 'You need Python2.6 or the simplejson module installed to use ' \ - 'this plugin. Download the module at ' \ - '.' - class Google(callbacks.PluginRegexp): threaded = True callBefore = ['Web'] @@ -132,11 +112,11 @@ class Google(callbacks.PluginRegexp): fd = utils.web.getUrlFd('%s?%s' % (self._gsearchUrl, urllib.urlencode(opts)), headers) - json = simplejson.load(fd) + resp = json.load(fd) fd.close() - if json['responseStatus'] != 200: + if resp['responseStatus'] != 200: raise callbacks.Error, 'We broke The Google!' - return json + return resp def formatData(self, data, bold=True, max=0): if isinstance(data, basestring): diff --git a/setup.py b/setup.py index a24f8ac8f..280e415ab 100644 --- a/setup.py +++ b/setup.py @@ -95,8 +95,6 @@ packages = ['supybot', [ 'supybot.plugins.Dict.local', 'supybot.plugins.Math.local', - 'supybot.plugins.Google.local', - 'supybot.plugins.Google.local.simplejson', 'supybot.plugins.RSS.local', 'supybot.plugins.Time.local', 'supybot.plugins.Time.local.dateutil', From 6c7aec165f03b3e7dbc1058c845e628c0ce71f64 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 10 Sep 2012 20:54:08 -0400 Subject: [PATCH 03/67] Remove a few more references to local.simplejson Signed-off-by: James McCoy --- setup.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.py b/setup.py index 280e415ab..8817afb70 100644 --- a/setup.py +++ b/setup.py @@ -104,9 +104,6 @@ package_dir = {'supybot': 'src', 'supybot.utils': 'src/utils', 'supybot.plugins': 'plugins', 'supybot.drivers': 'src/drivers', - 'supybot.plugins.Google.local': 'plugins/Google/local', - 'supybot.plugins.Google.local.simplejson': - 'plugins/Google/local/simplejson', 'supybot.plugins.Dict.local': 'plugins/Dict/local', 'supybot.plugins.Math.local': 'plugins/Math/local', 'supybot.plugins.RSS.local': 'plugins/RSS/local', From 7203ac383db5bcd2139fcf8520496804e68dc5d2 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 10 Sep 2012 22:25:54 -0400 Subject: [PATCH 04/67] setup.py: Use setuptools instead of distutils This will allow us to automatically install dependencies with easy_install instead of shipping stale copies with Supybot Signed-off-by: James McCoy --- setup.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/setup.py b/setup.py index 8817afb70..643d77301 100644 --- a/setup.py +++ b/setup.py @@ -38,16 +38,8 @@ if sys.version_info < (2, 6, 0): sys.stderr.write(os.linesep) sys.exit(-1) -import textwrap - -clean = False -while '--clean' in sys.argv: - clean = True - sys.argv.remove('--clean') - -import glob -import shutil import os.path +import textwrap from src.version import version @@ -58,8 +50,7 @@ def normalizeWhitespace(s): return ' '.join(s.split()) try: - from distutils.core import setup - from distutils.sysconfig import get_python_lib + from setuptools import setup except ImportError, e: s = normalizeWhitespace("""Supybot requires the distutils package to install. This package is normally included with Python, but for some @@ -77,16 +68,6 @@ except ImportError, e: sys.stderr.write(os.linesep*2) sys.exit(-1) -if clean: - previousInstall = os.path.join(get_python_lib(), 'supybot') - if os.path.exists(previousInstall): - try: - print 'Removing current installation.' - shutil.rmtree(previousInstall) - except Exception, e: - print 'Couldn\'t remove former installation: %s' % e - sys.exit(-1) - packages = ['supybot', 'supybot.utils', 'supybot.drivers', @@ -141,7 +122,8 @@ setup( 'Operating System :: OS Independent', 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', - 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', ], # Installation data From 52e71edaccf34b814787dcb6abe5080a3121c93e Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 10 Sep 2012 22:28:34 -0400 Subject: [PATCH 05/67] Time: Remove dateutil and add it to install_requires Signed-off-by: James McCoy --- plugins/Time/local/__init__.py | 1 - plugins/Time/local/dateutil/__init__.py | 9 - plugins/Time/local/dateutil/easter.py | 92 -- plugins/Time/local/dateutil/parser.py | 886 ------------- plugins/Time/local/dateutil/relativedelta.py | 432 ------- plugins/Time/local/dateutil/rrule.py | 1097 ----------------- plugins/Time/local/dateutil/tz.py | 951 -------------- plugins/Time/local/dateutil/tzwin.py | 180 --- .../Time/local/dateutil/zoneinfo/__init__.py | 87 -- .../dateutil/zoneinfo/zoneinfo-2008e.tar.gz | Bin 163209 -> 0 bytes setup.py | 12 +- 11 files changed, 6 insertions(+), 3741 deletions(-) delete mode 100644 plugins/Time/local/__init__.py delete mode 100644 plugins/Time/local/dateutil/__init__.py delete mode 100644 plugins/Time/local/dateutil/easter.py delete mode 100644 plugins/Time/local/dateutil/parser.py delete mode 100644 plugins/Time/local/dateutil/relativedelta.py delete mode 100644 plugins/Time/local/dateutil/rrule.py delete mode 100644 plugins/Time/local/dateutil/tz.py delete mode 100644 plugins/Time/local/dateutil/tzwin.py delete mode 100644 plugins/Time/local/dateutil/zoneinfo/__init__.py delete mode 100644 plugins/Time/local/dateutil/zoneinfo/zoneinfo-2008e.tar.gz diff --git a/plugins/Time/local/__init__.py b/plugins/Time/local/__init__.py deleted file mode 100644 index e86e97b86..000000000 --- a/plugins/Time/local/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Stub so local is a module, used for third-party modules diff --git a/plugins/Time/local/dateutil/__init__.py b/plugins/Time/local/dateutil/__init__.py deleted file mode 100644 index 8b4ac7dc8..000000000 --- a/plugins/Time/local/dateutil/__init__.py +++ /dev/null @@ -1,9 +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" -__version__ = "1.4.1" diff --git a/plugins/Time/local/dateutil/easter.py b/plugins/Time/local/dateutil/easter.py deleted file mode 100644 index d7944104b..000000000 --- a/plugins/Time/local/dateutil/easter.py +++ /dev/null @@ -1,92 +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 - -__all__ = ["easter", "EASTER_JULIAN", "EASTER_ORTHODOX", "EASTER_WESTERN"] - -EASTER_JULIAN = 1 -EASTER_ORTHODOX = 2 -EASTER_WESTERN = 3 - -def easter(year, method=EASTER_WESTERN): - """ - This method was ported from the work done by GM Arts, - on top of the algorithm by Claus Tondering, which was - based in part on the algorithm of Ouding (1940), as - quoted in "Explanatory Supplement to the Astronomical - Almanac", P. Kenneth Seidelmann, editor. - - 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 - calendar, valid in years 1583 to 4099 - 3 - Revised method, in Gregorian calendar, valid in - years 1583 to 4099 as well - - These methods are represented by the constants: - - 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 - - and - - http://www.tondering.dk/claus/calendar.html - - """ - - if not (1 <= method <= 3): - raise ValueError, "invalid method" - - # g - Golden year - 1 - # c - Century - # h - (23 - Epact) mod 30 - # i - Number of days from March 21 to Paschal Full Moon - # j - Weekday for PFM (0=Sunday, etc) - # p - Number of days from March 21 to Sunday on or before PFM - # (-6 to 28 methods 1 & 3, to 56 for method 2) - # e - Extra days to add for method 2 (converting Julian - # date to Gregorian date) - - y = year - g = y % 19 - e = 0 - if method < 3: - # Old method - 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 - 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 - - # 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)) - diff --git a/plugins/Time/local/dateutil/parser.py b/plugins/Time/local/dateutil/parser.py deleted file mode 100644 index 5d824e411..000000000 --- a/plugins/Time/local/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/plugins/Time/local/dateutil/relativedelta.py b/plugins/Time/local/dateutil/relativedelta.py deleted file mode 100644 index 562a7d3c4..000000000 --- a/plugins/Time/local/dateutil/relativedelta.py +++ /dev/null @@ -1,432 +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 calendar - -__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: - """ -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. - -There's two different ways to build a relativedelta instance. The -first one is passing it two date/datetime classes: - - relativedelta(datetime1, datetime2) - -And the other way is to use the following keyword arguments: - - year, month, day, hour, minute, second, microsecond: - Absolute information. - - years, months, weeks, days, hours, minutes, seconds, microseconds: - Relative information, may be negative. - - 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. - - leapdays: - Will add given days to the date found, if year is a leap - year, and the date found is post 28 of february. - - yearday, nlyearday: - Set the yearday or the non-leap year day (jump leap days). - These are converted to day/month/leapdays information. - -Here is the behavior of operations with relativedelta: - -1) Calculate the absolute year, using the 'year' argument, or the - original datetime year, if the argument is not present. - -2) Add the relative 'years' argument to the absolute year. - -3) Do steps 1 and 2 for month/months. - -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. - -5) Add the relative 'days' argument to the absolute day. Notice - that the 'weeks' argument is multiplied by 7 and added to - 'days'. - -6) Do steps 1 and 2 for hour/hours, minute/minutes, second/seconds, - microsecond/microseconds. - -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, - years=0, months=0, days=0, leapdays=0, weeks=0, - hours=0, minutes=0, seconds=0, microseconds=0, - 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): - 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 - self.leapdays = 0 - self.hours = 0 - self.minutes = 0 - self.seconds = 0 - self.microseconds = 0 - self.year = None - self.month = None - self.day = None - self.weekday = None - self.hour = None - self.minute = None - self.second = None - self.microsecond = None - self._has_time = 0 - - months = (dt1.year*12+dt1.month)-(dt2.year*12+dt2.month) - self._set_months(months) - dtm = self.__radd__(dt2) - if dt1 < dt2: - while dt1 > dtm: - months += 1 - self._set_months(months) - dtm = self.__radd__(dt2) - else: - while dt1 < dtm: - months -= 1 - self._set_months(months) - dtm = self.__radd__(dt2) - delta = dt1 - dtm - self.seconds = delta.seconds+delta.days*86400 - self.microseconds = delta.microseconds - else: - self.years = years - self.months = months - self.days = days+weeks*7 - self.leapdays = leapdays - self.hours = hours - self.minutes = minutes - self.seconds = seconds - self.microseconds = microseconds - self.year = year - self.month = month - self.day = day - self.hour = hour - self.minute = minute - self.second = second - self.microsecond = microsecond - - if type(weekday) is int: - self.weekday = weekdays[weekday] - else: - self.weekday = weekday - - yday = 0 - if nlyearday: - yday = nlyearday - elif yearday: - yday = yearday - if yearday > 59: - self.leapdays = -1 - if yday: - 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 - if idx == 0: - self.day = ydays - else: - self.day = yday-ydayidx[idx-1] - break - else: - 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 - 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 - 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 - 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 - 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): - self._has_time = 1 - else: - self._has_time = 0 - - 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 - else: - self.years = 0 - - def __radd__(self, other): - if not isinstance(other, datetime.date): - raise TypeError, "unsupported type for add operation" - elif self._has_time and not isinstance(other, datetime.datetime): - other = datetime.datetime.fromordinal(other.toordinal()) - year = (self.year or other.year)+self.years - month = self.month or other.month - if self.months: - assert 1 <= abs(self.months) <= 12 - month += self.months - if month > 12: - year += 1 - month -= 12 - elif month < 1: - year -= 1 - month += 12 - day = min(calendar.monthrange(year, month)[1], - self.day or other.day) - repl = {"year": year, "month": month, "day": day} - for attr in ["hour", "minute", "second", "microsecond"]: - value = getattr(self, attr) - if value is not None: - repl[attr] = value - days = self.days - if self.leapdays and month > 2 and calendar.isleap(year): - days += self.leapdays - ret = (other.replace(**repl) - + datetime.timedelta(days=days, - hours=self.hours, - minutes=self.minutes, - seconds=self.seconds, - microseconds=self.microseconds)) - if self.weekday: - weekday, nth = self.weekday.weekday, self.weekday.n or 1 - jumpdays = (abs(nth)-1)*7 - if nth > 0: - jumpdays += (7-ret.weekday()+weekday)%7 - else: - jumpdays += (ret.weekday()-weekday)%7 - jumpdays *= -1 - ret += datetime.timedelta(days=jumpdays) - return ret - - 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) - - def __neg__(self): - return relativedelta(years=-self.years, - months=-self.months, - days=-self.days, - hours=-self.hours, - minutes=-self.minutes, - seconds=-self.seconds, - microseconds=-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 __nonzero__(self): - return not (not self.years and - not self.months and - not self.days and - not self.hours and - not self.minutes and - not self.seconds and - not self.microseconds and - not self.leapdays and - self.year is None and - self.month is None and - self.day is None and - self.weekday is None and - self.hour is None and - self.minute is None and - self.second is None and - self.microsecond is None) - - 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, - 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 __eq__(self, other): - if not isinstance(other, relativedelta): - return False - if self.weekday or other.weekday: - if not self.weekday or not other.weekday: - return False - if self.weekday.weekday != other.weekday.weekday: - return False - n1, n2 = self.weekday.n, other.weekday.n - if n1 != n2 and not ((not n1 or n1 == 1) and (not n2 or n2 == 1)): - return False - return (self.years == other.years and - self.months == other.months and - self.days == other.days and - self.hours == other.hours and - self.minutes == other.minutes and - self.seconds == other.seconds and - self.leapdays == other.leapdays and - self.year == other.year and - self.month == other.month and - self.day == other.day and - self.hour == other.hour and - self.minute == other.minute and - self.second == other.second and - self.microsecond == other.microsecond) - - def __ne__(self, other): - return not self.__eq__(other) - - def __div__(self, other): - return self.__mul__(1/float(other)) - - def __repr__(self): - l = [] - for attr in ["years", "months", "days", "leapdays", - "hours", "minutes", "seconds", "microseconds"]: - value = getattr(self, attr) - if value: - l.append("%s=%+d" % (attr, 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)) - -# vim:ts=4:sw=4:et diff --git a/plugins/Time/local/dateutil/rrule.py b/plugins/Time/local/dateutil/rrule.py deleted file mode 100644 index 4c21d2d1d..000000000 --- a/plugins/Time/local/dateutil/rrule.py +++ /dev/null @@ -1,1097 +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 itertools -import datetime -import calendar -import thread -import sys - -__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+ - [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) -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) -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 -del M29, M30, M31, M365MASK[59], MDAY365MASK[59], NMDAY365MASK[31] -MDAY365MASK = tuple(MDAY365MASK) -M365MASK = tuple(M365MASK) - -(YEARLY, - MONTHLY, - WEEKLY, - DAILY, - HOURLY, - MINUTELY, - SECONDLY) = range(7) - -# Imported on demand. -easter = None -parser = None - -class weekday(object): - __slots__ = ["weekday", "n"] - - def __init__(self, weekday, n=None): - if n == 0: - raise ValueError, "Can't create weekday with n == 0" - 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 rrulebase: - def __init__(self, cache=False): - if cache: - self._cache = [] - self._cache_lock = thread.allocate_lock() - self._cache_gen = self._iter() - self._cache_complete = False - else: - self._cache = None - self._cache_complete = False - self._len = None - - def __iter__(self): - if self._cache_complete: - return iter(self._cache) - elif self._cache is None: - return self._iter() - else: - return self._iter_cached() - - def _iter_cached(self): - i = 0 - gen = self._cache_gen - cache = self._cache - acquire = self._cache_lock.acquire - release = self._cache_lock.release - while gen: - if i == len(cache): - acquire() - if self._cache_complete: - break - try: - for j in range(10): - cache.append(gen.next()) - except StopIteration: - self._cache_gen = gen = None - self._cache_complete = True - break - release() - yield cache[i] - i += 1 - while i < self._len: - yield cache[i] - i += 1 - - def __getitem__(self, item): - if self._cache_complete: - return self._cache[item] - elif isinstance(item, slice): - if item.step and item.step < 0: - return list(iter(self))[item] - else: - return list(itertools.islice(self, - item.start or 0, - item.stop or sys.maxint, - item.step or 1)) - elif item >= 0: - gen = iter(self) - try: - for i in range(item+1): - res = gen.next() - except StopIteration: - raise IndexError - return res - else: - return list(iter(self))[item] - - def __contains__(self, item): - if self._cache_complete: - return item in self._cache - else: - for i in self: - if i == item: - return True - elif i > item: - return False - return False - - # __len__() introduces a large performance penality. - def count(self): - if self._len is None: - for x in self: pass - return self._len - - def before(self, dt, inc=False): - if self._cache_complete: - gen = self._cache - else: - gen = self - last = None - if inc: - for i in gen: - if i > dt: - break - last = i - else: - for i in gen: - if i >= dt: - break - last = i - return last - - def after(self, dt, inc=False): - if self._cache_complete: - gen = self._cache - else: - gen = self - if inc: - for i in gen: - if i >= dt: - return i - else: - for i in gen: - if i > dt: - return i - return None - - def between(self, after, before, inc=False): - if self._cache_complete: - gen = self._cache - else: - gen = self - started = False - l = [] - if inc: - for i in gen: - if i > before: - break - elif not started: - if i >= after: - started = True - l.append(i) - else: - l.append(i) - else: - for i in gen: - if i >= before: - break - elif not started: - if i > after: - started = True - l.append(i) - else: - l.append(i) - return l - -class rrule(rrulebase): - 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) - global easter - if not dtstart: - dtstart = datetime.datetime.now().replace(microsecond=0) - elif not isinstance(dtstart, datetime.datetime): - dtstart = datetime.datetime.fromordinal(dtstart.toordinal()) - else: - dtstart = dtstart.replace(microsecond=0) - self._dtstart = dtstart - self._tzinfo = dtstart.tzinfo - self._freq = freq - self._interval = interval - self._count = count - if until and not isinstance(until, datetime.datetime): - until = datetime.datetime.fromordinal(until.toordinal()) - self._until = until - if wkst is None: - self._wkst = calendar.firstweekday() - elif type(wkst) is int: - self._wkst = wkst - else: - self._wkst = wkst.weekday - if bysetpos is None: - self._bysetpos = None - elif type(bysetpos) is int: - if bysetpos == 0 or not (-366 <= bysetpos <= 366): - raise ValueError("bysetpos must be between 1 and 366, " - "or between -366 and -1") - self._bysetpos = (bysetpos,) - else: - self._bysetpos = tuple(bysetpos) - for pos in self._bysetpos: - 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 freq == YEARLY: - if not bymonth: - bymonth = dtstart.month - bymonthday = dtstart.day - elif freq == MONTHLY: - bymonthday = dtstart.day - elif freq == WEEKLY: - byweekday = dtstart.weekday() - # bymonth - if not bymonth: - self._bymonth = None - elif type(bymonth) is int: - self._bymonth = (bymonth,) - else: - self._bymonth = tuple(bymonth) - # byyearday - if not byyearday: - self._byyearday = None - elif type(byyearday) is int: - self._byyearday = (byyearday,) - else: - self._byyearday = tuple(byyearday) - # byeaster - if byeaster is not None: - if not easter: - from dateutil import easter - if type(byeaster) is int: - self._byeaster = (byeaster,) - else: - self._byeaster = tuple(byeaster) - else: - self._byeaster = None - # bymonthay - if not bymonthday: - 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]) - # byweekno - if byweekno is None: - self._byweekno = None - elif type(byweekno) is int: - self._byweekno = (byweekno,) - else: - self._byweekno = tuple(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 = [] - for wday in byweekday: - if type(wday) is int: - self._byweekday.append(wday) - elif not wday.n or freq > MONTHLY: - self._byweekday.append(wday.weekday) - else: - self._bynweekday.append((wday.weekday, wday.n)) - self._byweekday = tuple(self._byweekday) - self._bynweekday = tuple(self._bynweekday) - if not self._byweekday: - self._byweekday = None - elif not self._bynweekday: - self._bynweekday = None - # byhour - if byhour is None: - if freq < HOURLY: - self._byhour = (dtstart.hour,) - else: - self._byhour = None - elif type(byhour) is int: - self._byhour = (byhour,) - else: - self._byhour = tuple(byhour) - # byminute - if byminute is None: - if freq < MINUTELY: - self._byminute = (dtstart.minute,) - else: - self._byminute = None - elif type(byminute) is int: - self._byminute = (byminute,) - else: - self._byminute = tuple(byminute) - # bysecond - if bysecond is None: - if freq < SECONDLY: - self._bysecond = (dtstart.second,) - else: - self._bysecond = None - elif type(bysecond) is int: - self._bysecond = (bysecond,) - else: - self._bysecond = tuple(bysecond) - - if self._freq >= HOURLY: - self._timeset = None - else: - self._timeset = [] - for hour in self._byhour: - for minute in self._byminute: - for second in self._bysecond: - self._timeset.append( - datetime.time(hour, minute, second, - tzinfo=self._tzinfo)) - self._timeset.sort() - self._timeset = tuple(self._timeset) - - def _iter(self): - year, month, day, hour, minute, second, weekday, yearday, _ = \ - self._dtstart.timetuple() - - # Some local variables to speed things up a bit - freq = self._freq - interval = self._interval - wkst = self._wkst - until = self._until - bymonth = self._bymonth - byweekno = self._byweekno - byyearday = self._byyearday - byweekday = self._byweekday - byeaster = self._byeaster - bymonthday = self._bymonthday - bynmonthday = self._bynmonthday - bysetpos = self._bysetpos - byhour = self._byhour - byminute = self._byminute - bysecond = self._bysecond - - 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] - - if freq < HOURLY: - timeset = self._timeset - else: - 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 - self._byminute and minute not in self._byminute) or - (freq >= SECONDLY and - self._bysecond and minute not in self._bysecond)): - timeset = () - else: - timeset = gettimeset(hour, minute, second) - - total = 0 - count = self._count - while True: - # Get dayset with the right frequency - dayset, start, end = getdayset(year, month, day) - - # Do the "hard" work ;-) - filtered = False - for i in dayset[start:end]: - if ((bymonth and ii.mmask[i] not in bymonth) or - (byweekno and not ii.wnomask[i]) or - (byweekday and ii.wdaymask[i] not in byweekday) or - (ii.nwdaymask and not ii.nwdaymask[i]) or - (byeaster and not ii.eastermask[i]) or - ((bymonthday or bynmonthday) and - 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)))): - dayset[i] = None - filtered = True - - # Output results - if bysetpos and timeset: - poslist = [] - for pos in bysetpos: - if pos < 0: - daypos, timepos = divmod(pos, len(timeset)) - else: - daypos, timepos = divmod(pos-1, len(timeset)) - try: - i = [x for x in dayset[start:end] - if x is not None][daypos] - time = timeset[timepos] - except IndexError: - pass - else: - date = datetime.date.fromordinal(ii.yearordinal+i) - res = datetime.datetime.combine(date, time) - if res not in poslist: - poslist.append(res) - poslist.sort() - for res in poslist: - if until and res > until: - self._len = total - return - elif res >= self._dtstart: - total += 1 - yield res - if count: - count -= 1 - if not count: - self._len = total - return - else: - for i in dayset[start:end]: - if i is not None: - 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: - count -= 1 - if not count: - self._len = total - return - - # Handle frequency and interval - fixday = False - if freq == YEARLY: - year += interval - if year > datetime.MAXYEAR: - self._len = total - return - ii.rebuild(year, month) - elif freq == MONTHLY: - month += interval - if month > 12: - div, mod = divmod(month, 12) - month = mod - year += div - if month == 0: - month = 12 - year -= 1 - if year > datetime.MAXYEAR: - self._len = total - return - ii.rebuild(year, month) - elif freq == WEEKLY: - if wkst > weekday: - day += -(weekday+1+(6-wkst))+self._interval*7 - else: - day += -(weekday-wkst)+self._interval*7 - weekday = wkst - fixday = True - elif freq == DAILY: - day += interval - fixday = True - elif freq == HOURLY: - 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 - 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) - 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)): - break - 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) - if div: - second = mod - minute += div - div, mod = divmod(minute, 60) - if div: - minute = mod - hour += div - div, mod = divmod(hour, 24) - if div: - hour = mod - 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)): - break - timeset = gettimeset(hour, minute, second) - - if fixday and day > 28: - daysinmonth = calendar.monthrange(year, month)[1] - if day > daysinmonth: - while day > daysinmonth: - day -= daysinmonth - month += 1 - if month == 13: - month = 1 - year += 1 - if year > datetime.MAXYEAR: - self._len = total - return - daysinmonth = calendar.monthrange(year, month)[1] - ii.rebuild(year, month) - -class _iterinfo(object): - __slots__ = ["rrule", "lastyear", "lastmonth", - "yearlen", "nextyearlen", "yearordinal", "yearweekday", - "mmask", "mrange", "mdaymask", "nmdaymask", - "wdaymask", "wnomask", "nwdaymask", "eastermask"] - - def __init__(self, rrule): - for attr in self.__slots__: - setattr(self, attr, None) - self.rrule = rrule - - def rebuild(self, year, month): - # 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) - firstyday = datetime.date(year, 1, 1) - self.yearordinal = firstyday.toordinal() - self.yearweekday = firstyday.weekday() - - wday = datetime.date(year, 1, 1).weekday() - if self.yearlen == 365: - self.mmask = M365MASK - self.mdaymask = MDAY365MASK - self.nmdaymask = NMDAY365MASK - self.wdaymask = WDAYMASK[wday:] - self.mrange = M365RANGE - else: - self.mmask = M366MASK - self.mdaymask = MDAY366MASK - self.nmdaymask = NMDAY366MASK - self.wdaymask = WDAYMASK[wday:] - self.mrange = M366RANGE - - if not rr._byweekno: - 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 - 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 - else: - # Number of days in the year, minus the days we - # left in last year. - wyearlen = self.yearlen-no1wkst - div, mod = divmod(wyearlen, 7) - numweeks = div+mod//4 - for n in rr._byweekno: - if n < 0: - n += numweeks+1 - if not (0 < n <= numweeks): - continue - if n > 1: - i = no1wkst+(n-1)*7 - if no1wkst != firstwkst: - i -= 7-firstwkst - else: - i = no1wkst - for j in range(7): - self.wnomask[i] = 1 - i += 1 - if self.wdaymask[i] == rr._wkst: - break - if 1 in rr._byweekno: - # Check week number 1 of next year as well - # TODO: Check -numweeks for next year. - i = no1wkst+numweeks*7 - if no1wkst != firstwkst: - i -= 7-firstwkst - if i < self.yearlen: - # If week starts in next year, we - # don't care about it. - for j in range(7): - self.wnomask[i] = 1 - i += 1 - if self.wdaymask[i] == rr._wkst: - break - if no1wkst: - # Check last week number of last year as - # well. If no1wkst is 0, either the year - # started on week start, or week number 1 - # got days from last year, so there are no - # 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 - lyearlen = 365+calendar.isleap(year-1) - if lno1wkst >= 4: - lno1wkst = 0 - lnumweeks = 52+(lyearlen+ - (lyearweekday-rr._wkst)%7)%7//4 - else: - 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)): - ranges = [] - if rr._freq == YEARLY: - if rr._bymonth: - for month in rr._bymonth: - ranges.append(self.mrange[month-1:month+1]) - else: - ranges = [(0, self.yearlen)] - elif rr._freq == MONTHLY: - ranges = [self.mrange[month-1:month+1]] - if ranges: - # Weekly frequency won't get here, so we may not - # care about cross-year weekly periods. - self.nwdaymask = [0]*self.yearlen - for first, last in ranges: - last -= 1 - for wday, n in rr._bynweekday: - if n < 0: - i = last+(n+1)*7 - i -= (self.wdaymask[i]-wday)%7 - else: - i = first+(n-1)*7 - i += (7-self.wdaymask[i]+wday)%7 - if first <= i <= last: - self.nwdaymask[i] = 1 - - if rr._byeaster: - self.eastermask = [0]*(self.yearlen+7) - eyday = easter.easter(year).toordinal()-self.yearordinal - for offset in rr._byeaster: - self.eastermask[eyday+offset] = 1 - - self.lastyear = year - self.lastmonth = month - - def ydayset(self, year, month, day): - return range(self.yearlen), 0, self.yearlen - - def mdayset(self, year, month, day): - set = [None]*self.yearlen - start, end = self.mrange[month-1:month+1] - for i in range(start, end): - set[i] = i - return set, start, end - - def wdayset(self, year, month, day): - # We need to handle cross-year weeks here. - set = [None]*(self.yearlen+7) - i = datetime.date(year, month, day).toordinal()-self.yearordinal - start = i - for j in range(7): - set[i] = i - i += 1 - #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 - - 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 - - def htimeset(self, hour, minute, second): - set = [] - 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 - - def mtimeset(self, hour, minute, second): - set = [] - rr = self.rrule - for second in rr._bysecond: - set.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo)) - set.sort() - return set - - def stimeset(self, hour, minute, second): - return (datetime.time(hour, minute, second, - tzinfo=self.rrule._tzinfo),) - - -class rruleset(rrulebase): - - class _genitem: - def __init__(self, genlist, gen): - try: - self.dt = gen() - genlist.append(self) - except StopIteration: - pass - self.genlist = genlist - self.gen = gen - - def next(self): - try: - self.dt = self.gen() - except StopIteration: - self.genlist.remove(self) - - def __cmp__(self, other): - return cmp(self.dt, other.dt) - - def __init__(self, cache=False): - rrulebase.__init__(self, cache) - self._rrule = [] - self._rdate = [] - self._exrule = [] - self._exdate = [] - - def rrule(self, rrule): - self._rrule.append(rrule) - - def rdate(self, rdate): - self._rdate.append(rdate) - - def exrule(self, exrule): - self._exrule.append(exrule) - - def exdate(self, exdate): - 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, 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, gen) - exlist.sort() - lastdt = None - total = 0 - while rlist: - ritem = rlist[0] - if not lastdt or lastdt != ritem.dt: - while exlist and exlist[0] < ritem: - exlist[0].next() - exlist.sort() - if not exlist or ritem != exlist[0]: - total += 1 - yield ritem.dt - lastdt = ritem.dt - ritem.next() - rlist.sort() - self._len = total - -class _rrulestr: - - _freq_map = {"YEARLY": YEARLY, - "MONTHLY": MONTHLY, - "WEEKLY": WEEKLY, - "DAILY": DAILY, - "HOURLY": HOURLY, - "MINUTELY": MINUTELY, - "SECONDLY": SECONDLY} - - _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) - - 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_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 - - def _handle_FREQ(self, rrkwargs, name, value, **kwargs): - rrkwargs["freq"] = self._freq_map[value] - - def _handle_UNTIL(self, rrkwargs, name, value, **kwargs): - global parser - if not parser: - from dateutil import parser - try: - rrkwargs["until"] = parser.parse(value, - ignoretz=kwargs.get("ignoretz"), - tzinfos=kwargs.get("tzinfos")) - except ValueError: - 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): - 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) - l.append(weekdays[self._weekday_map[w]](n)) - rrkwargs["byweekday"] = l - - _handle_BYDAY = _handle_BYWEEKDAY - - def _parse_rfc_rrule(self, line, - dtstart=None, - cache=False, - ignoretz=False, - tzinfos=None): - if line.find(':') != -1: - name, value = line.split(':') - if name != "RRULE": - raise ValueError, "unknown parameter name" - else: - value = line - rrkwargs = {} - for pair in value.split(';'): - name, value = pair.split('=') - name = name.upper() - value = value.upper() - try: - getattr(self, "_handle_"+name)(rrkwargs, name, value, - ignoretz=ignoretz, - tzinfos=tzinfos) - except AttributeError: - raise ValueError, "unknown parameter '%s'" % name - except (KeyError, ValueError): - raise ValueError, "invalid '%s': %s" % (name, value) - return rrule(dtstart=dtstart, cache=cache, **rrkwargs) - - def _parse_rfc(self, s, - dtstart=None, - cache=False, - unfold=False, - forceset=False, - compatible=False, - ignoretz=False, - tzinfos=None): - global parser - if compatible: - forceset = True - unfold = True - s = s.upper() - if not s.strip(): - raise ValueError, "empty string" - if unfold: - lines = s.splitlines() - 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 - else: - lines = s.split() - 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) - else: - rrulevals = [] - rdatevals = [] - exrulevals = [] - exdatevals = [] - for line in lines: - if not line: - continue - if line.find(':') == -1: - name = "RRULE" - value = line - else: - name, value = line.split(':', 1) - parms = name.split(';') - if not parms: - raise ValueError, "empty property name" - name = parms[0] - parms = parms[1:] - if name == "RRULE": - for parm in parms: - 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 - rdatevals.append(value) - elif name == "EXRULE": - for parm in parms: - 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 - exdatevals.append(value) - elif name == "DTSTART": - for parm in parms: - raise ValueError, "unsupported DTSTART parm: "+parm - if not parser: - from dateutil import parser - dtstart = parser.parse(value, ignoretz=ignoretz, - tzinfos=tzinfos) - else: - 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) - 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, - ignoretz=ignoretz, - tzinfos=tzinfos)) - for value in exdatevals: - for datestr in value.split(','): - set.exdate(parser.parse(datestr, - ignoretz=ignoretz, - tzinfos=tzinfos)) - if compatible and dtstart: - set.rdate(dtstart) - return set - else: - return self._parse_rfc_rrule(rrulevals[0], - dtstart=dtstart, - cache=cache, - ignoretz=ignoretz, - tzinfos=tzinfos) - - def __call__(self, s, **kwargs): - return self._parse_rfc(s, **kwargs) - -rrulestr = _rrulestr() - -# vim:ts=4:sw=4:et diff --git a/plugins/Time/local/dateutil/tz.py b/plugins/Time/local/dateutil/tz.py deleted file mode 100644 index 0e28d6b33..000000000 --- a/plugins/Time/local/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/plugins/Time/local/dateutil/tzwin.py b/plugins/Time/local/dateutil/tzwin.py deleted file mode 100644 index 073e0ff68..000000000 --- a/plugins/Time/local/dateutil/tzwin.py +++ /dev/null @@ -1,180 +0,0 @@ -# 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 diff --git a/plugins/Time/local/dateutil/zoneinfo/__init__.py b/plugins/Time/local/dateutil/zoneinfo/__init__.py deleted file mode 100644 index 9bed6264c..000000000 --- a/plugins/Time/local/dateutil/zoneinfo/__init__.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Copyright (c) 2003-2005 Gustavo Niemeyer - -This module offers extensions to the standard python 2.3+ -datetime module. -""" -from dateutil.tz import tzfile -from tarfile import TarFile -import os - -__author__ = "Gustavo Niemeyer " -__license__ = "PSF License" - -__all__ = ["setcachesize", "gettz", "rebuild"] - -CACHE = [] -CACHESIZE = 10 - -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() - -del getzoneinfofile - -def setcachesize(size): - global CACHESIZE, CACHE - CACHESIZE = size - del CACHE[size:] - -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 - -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) diff --git a/plugins/Time/local/dateutil/zoneinfo/zoneinfo-2008e.tar.gz b/plugins/Time/local/dateutil/zoneinfo/zoneinfo-2008e.tar.gz deleted file mode 100644 index 65e4175f4a4bb008acd62264e3bee1ca9b2c01fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 163209 zcmYIwby!qi)UHamfPgduLkmcEIJ7AIBn1RSK%|E57)1mDfsqs`DUp`$GDrad0i_0{ zyK5Nc-0gSo_uN0$d-mFUt#_@x_TGo%ob$*TOG+v(`(Ec7B=*eR%|Q(5*M75{~JN>NCzk6Q+KidZHP$RzAfh<>SOD5Wy^{rguZ94Rryc1%uS75 zt{{@8G1}igl-Dv?NE55Nh#z_6GQFa64)-8M|GY;9yYT>_E0E`Zz3t^1y9Nb2>LRD6 zt1IFPP1!qRQ>GZ7G>FM%mX`$NK_89bgF=IAbx|mbZQQFdCR=qi_>VNEWcyis?`5aq>75wi3P1 z6r&OAN!@*AYjLlsX0o?qgK@5sS{?D3%rx$)mnT7L@J65mWV>LiopgVzrSIe3A17~l zk4L@Dzb8}6-AV2+k6NeaHyGFtRUA_`DbN_NAV8PLmrGTCUBDz*dn^) zQ`n(Un)qj7*#9opO6U8dHE&qdQKd!4m0Y(_6qbSO>}Mnm=K9Tx)3L>OXX=%Iqo;}J z&PscRx=j%ZCJ{c(&vD7LqfR^pM%p?>321#7EK3WakP_jeK(?MtH|j)mV5Fy0l!n$9 zN9R06D2xLSFhD=*#9v@!pi`8C7QGtE+)NqaBS^aLHSc<$htNrTW$oicx_)qdl$X0; zKtm@j=1+8ty}KewFuvYPT!5p8u~`@Oq=rI~sBXtO!$7=nsy24=DK6&n ztI5TM1AXRsRLY|)qVr=sy}Fo?j-~ z2j%+y!p=({n>7%n+0H(v+17b`mQ-srHH3(#pZG1znVIO<5Ao`eUh;=vJg!}}Fla|r zIJFD9DEJ;##=VY-{`5;J&tF4f4P&*9ufVs>K61D$%Ex1bryENbtIJ|7?YNp^#G|42 z_bR9^69}R$ysfn{C#7vn6I<A}VnMsYlMEk}0QEXc;k@Qa9L&raf%2&HY zDbhKFh@xc8@iquZ_BdDF**KfN1zR}&RuCg! zd>8+VU3dJF#Bb$??62zTF@5OxNlQ;8QMXp%U8c^C4a~9CWpGf_z-7mJF)H;eDRWx4 zuAS!Kw5VVCXgbc|oP~feeCEWB zf40bvbX0szU4eR`h)vHgZg>JU_H%FTdZ-U}4N~4l(aKt0SfSk%un4R1gv{mA{>jlt_ONb-;;1Z%Ng2-rS92N?05wE10 z=_Qs!>BkU>KUgTCrU7E~Q|#$Tl$K_}gt$r>N>6a0{iSJ3g(DTW zakhoNtA|%pt;BZC{dXIYe`*|~k-O(!siCo=4^Q1u_VTyeOUDmfR0p~i6gG3kW9uf{ zn%62+OA6Z31Iu?-9%L;~Y_HZ&cSMo-Q&U#WX5rKt9W7{`HqH7yP+v@@Us7+siW(u} z<{ZE27%&s`bje1unl(Fsy;@oKlbF)L>h<%jFUW=S8s~bZ3>#+a?77Fm((a!Yj+;r| zl3^<7BA5$Z%ciMb$`ynb7ztP+rPMWb@8`|7^UVD_aq+ku=l& z<8%$ok)=D(9;#YaRqp(6{=t3AE~@a{pCi4!UnG?yxJy@l$FMXr8z&`bGmaTB#GLNl zH2-48n@o9&-n=BaBiCn82M_WevXid{rg20tGcfP zwd0jgS!vZ@61TxD0^ho@*W(k`dvz81K9=QA5h$10_>t70HU*1N=lc4y`ZBljoW+#- z7p47Eol{oR0xi$nY86@vtG27_`U;{_KNh{KFyGB*?5Iog8JsSwuRU|h(6?NDUGLCg z<5M0}F^X;AEI%}Om*-|x9#wICj-RUku5B@m-1_7u7HKgpw&t?#QB{0g`hfiwyR2EY zA9=CUd61us%c_>!8QUu<)mOkH11X}&(j z=(aYf+q}~qIK>f_W*Mv%|0}Zm-7_k8)gHQib$1v0I@(n0k?pbv=Pl-Wm!+2ZInwpt zCR|HnmCd_Gi~f_g%Bd4gZ5N~1zz&L5Zlka%$j+a)re2`3C#gm;Lc`Nk@t_dgSp|c-0%!;cxfKf9|o8?V_R=MXW=@MtT?|KgK!l!xRo4oJ~UN{9GoR!#1m=CT^9BCbjn(&8Yl z0-kcqBGDymh!Q{22giG9wrA#SyBSXgx}u2P2syj?!f8&kh*WSN$$Bp8 zmu~O5`}e#^sHPz1th-Gda6#gx@38jn!81|&6GCp<$URLfWVl#1L>LmCLlEC^G@Ug6F=nzW&G(W% zBrtV$UYO+`iH&D4?b)sgzH>hebD?~;;X2l7CKf|zKnv>pouY0|6u{b1h_c zG-!U)vVjnDyB&U}W#I4OZ9^5`An1?W8{Ce0=^nNnbLn30OwxY*NPllTurl1;2D%y2 zlaw{DSj0(!acB#_+~ufc3`qziIh)fr>N@=nk+*krv9Rqvx4qR^cr-ju;%hq}#YP;? zIZ=B>HRRZ3FMl~`K0v#{r87bC`~|1Y6zzqoXFmB^-+o<}!FE4&m;!<*>}i+!UX6&x zp10@SEhuDpV1B}yrc=y*mO89znRYMMKy)D>&C2YBjY>Ne&iO=e-pmZT7PcLR+S^Rh zeuUlqg<`>iKTlahHsMA?rD&BG-$zC@l^5`KO@5(x9 ze6DyZM3^1u5U9X0kQYj|BOy)^FtI^Y1-Xv+ccq*^Kviww8TYn^hhDDw*idI5F7fTn zygIRinkI*?4j+yZW}g<(&Yur`d$G!?K+z-oS(Lc-@%-&Q#MsnxrV1uXYx~D0dwJhk z!%M?07X!Y3^P2y@8T^u|WBkg6n7NQ@K%cThm)>;S6|u_<$$<0$$F70+CMs(rMslko z-Co7`IOnyzX2v+o`#oti(d6Vz961C&ZV7cGi35O{rX@&JCIT? zdo|{YM`g#gt~S)(waTC$Os!LDD7H#$$>_YaLQwc*<`SuO=~L^ysVdp7%^M-a73*8i z7oDs#i7U-66av}?3_hbU+$~+94cUa1P!Mgv%O+H~tkS7+K>os57whgB)rC8q6k+SS z80LlS;rPpoxHnhat(|ViPv%+bd6dUew>qf%@1^>0ytqi~quS}Hga^133dc_dRS^WR zvL3LxC>G0|V0Ny67G|2d^{e5M{E5kC={$Q6&v2D~wqjSuPOMUo!m2fCg-euDk559+ z3ldY!Y`Yh$Vc++|4`9PpAE@lQUgmHQS5-)U>1@gR`8;&p0Rw2nxl<9F#fv&VEla!x=*Qq zpQF+N+hz+zVEl2cTDILK3Iryw;2N%ak^gq}_XnX~Z&!~S2W+f~RDto#7O-LQuWeKF z>a*z8-#_WWSOE2syi5ocFxbBm7$2%g6c`_;6u=o`Yy)Ix&xSI04||`Z@Nn4N{Ay3&|JuXHe0K5mc0vD z=Exm7PhWv@TsDD>UaWw*bn-08`+Xjj+%dYxLK~fj$43J*By-g0?Uvgxy2#I6kEs17pU6;d8#uH|WG)7OB*L0>bj3BXDn0MjOEb|?w37-J69 z|8Y(MTAO?Tg6$dhlv)5|>)T%MC+)8QgozwvK70&_9{vXcFp{^CTFbElt$+c*630R2 zCojNgLJ~$ImQ4_-f2*As05~4{x8#Wc_>uycWdcy6bfeW(aY?>v#uz{dIHXlyt^jx< z4q(&_gmQ>f;x{dR0ifx*5`Qxzn5>m7nEs9*m~5~Tn2RS!_ekntS6e;TaP%BFA5X@? z`FK*-4dB=oK%5tVK~efH7kS`Vm+=qWf$jSkK(TTiq$~y|`_Nq;ka@ELvN(|Z<}N4< zs|K*8LV;qj)`4QNLa&whWjqHc=cE_|^<7P`16vBUZ2C)Uz$OAt$T!9!0I5wf;SD%2 zZ`WgTU;%;rDCJ_6`0IB#N#>p^A7uWn2l0L*6#&eU1nO_y2KB&|5CXNzD0EMsd`W(P zlepLW?_?T!Rl~|@N&Z0tC>Es;2t~jIGP0`R9*(}V2tv)ffvHV$0B9%#pkuToFMbpZ zY8U+KcwYGnShQ?#Oul^v$K=Uxa7@JWBn~ofdJ=-5DbjSUlXpSa22yx$EgBg7@C2Gn zH%aTMGhi^_2e4}oz(xcBigQWcQ_U8@;|%_J0&w=DUrK`<=Orh*s5HUu``SD^kAZ_i zsIP*tuR}Zlw1LVaqfZ3M+zixbllQ)U_6ZAo)*b+OXSgtLrw)?yGzV$!vH&N{EU0-; z+CSI@HRqROB+2;#``m!5pcbeXzZig39gvrHbh-6p6 z-X3%TM)zp|461^PSW<&BT3&$GbZQ4NW{v~ci~n;nX8<4#PSi}+F4tLhC75L!CD6>k zwwv+48mO=I9|*4WnJiGhw)N)KFb~Fq%z!np_yK1bbMxBuU;}9%Z+9jPg6tAxL3Rd| zpyCFp|9}XDD*`veZ<6ilUBfdVt6>wc`-isyl#7Bh<4Xj17Gwa3QwQp|)`HF1%Jxc$ z-`YF^fZM;;1uf{?0cUsaHHhH8a+5f$R2n#J{&Vf22ZP=$0Mfv5Ydr;Zd=|V#6owK4 zi#yK-z~c&lX9NJ`{+$gMV$k{q!h|RQHN=z-z#N!bNG>=MHZuRf8YE|vrUk&=9PtPy z$2BY%q1o$A?gt75i&xJ!5~$}H7TgNr2C%x>#r_^PEGQADooys%ssTU~*k{%RJa{H8~5}sU|XKuod3fQIJ`2|vW=F&vKlQ}!~u_4kj-01 zP+x9$a9T9mReHT+aIfC3-XNL+*5s{-Y@-{aAO?AsCTKkX!DGsHDJ$sEt2au(gGkI1 zQ{c7l4jg!Y05#DHvQ*D-{Vyf?7SOmpaJIdjxi6UWg1Zex(KM$WH-5NEB>{NjbvhWAoxfZ0s23gn!d4HOs0L&up zH08a9>*b%5Fb9@ls3FVi*DLoTt!RMHycj+hBO)bqIGa;J^lol(rYj2HxOwP}%P8YQ`J$Zb2N^&JJ0d5LMI`U^-JQ`Njf6we8 zeU#8Pw%3yw0DlhEf30Hkyv0;TqNduHAgC(5S9rvE;|9xY6Y_(~e>o>u|HGi+L+Aj@ z38@}I?btcq2awC$M)odazR~%R_+rDaiU94c>R(2B&>(^!Kip`29~utC4_jJ8B45{Y zR-nv*Xr75J?6pG_{%~l?KBpU7SwyqTF)i74&{3qYjLWIUj~PL`dfWYtrIO@s0ya;l z@=dJoX8)q6bG=jB%mulnp*=9EjoeQd>Ac!B#QBwfLd8*uY)`ac2nDzTvC8h}gGbjm zlb{wK-MSSB{Qf&OE%N=a19^Z`WiEuoEDfD=a(9}&jA|Ss?>AhyG#FRFUH>%vt`^A{ z`s~(6wCy*oVH+i$=CHbe`%>{K=T?iEjO5lnrfBve|IY7K!H08A?Ai%T2;9d46N{0y z$zIhaKhY_9p^kaf>uOlBg$28cx`+%GnDq{WX=?RP_i<2ujf*??i8~Fjm$l;3p=8< z|Be#Blr1_(T~i;^h2{jY(9F^~O7h-?s*~bsbxV&r8bNZ|Kr`1fRo&(FxFo!mw-e zpL|LoqakQ}V@GgP_2n|ED3{8wGO&jDc6r}h6#wN#hGx*nt6j+41ts_PgA=rCMR{fu zzj$x@0Ji|W#q)g0=MIYcIEhuA6I_9kcOl1C94Dq}RZ8*F#$PZ?wf){Y&hwF>!s%k7 z>%<1n0i^18>c6JSIg9hTfs2w${5zN{YBZL^dE49{)&H_TE_`?lrDpuhUSs&E52~mT zJl@^mtAtV>9H17yZEw|ZxC#ZH3_J9D^D=IV9w`^JdWifED!iN3E8MYDH|N|wo6292 zEn2yl)Z02N{P1x0miqvzxu&9XuCP9QQ*4vGe9K)}NwTu8mnNMjmb$IfNK+>I)<;fP zk+}^udor6>bp=t+giZP_%`9rBRwi)mL$i~@qhD)Uq}TVel4*`!znRufjW?8dJj!Lu zUqA>)aR$M=itrgdbSmf5iA*E1d87)adFsCZmUh zU|Hq}g_Z~#Jf)&Hw{{iOj>lwU_uhp)=vrTZcC(;ay&Xu`07C74F3+W^e<%0$sb{c1 zRgnQZbljEIM}a7;!ynsXIo3~bX8ji(&ViRjl#@^r@6HDN1@gz-po(F1MzsNz?$wQf zSttt*?|?4Iq7qrdpcnWIZSWe#hb;Pabl-LC>0d^!P1VJ9R1oD6%ArU~U@oRi(@tuc-)u$|H*{$mY*YENxAbDbXQ`~e;6d_Quv(?M<%aJwOPh_r zvrP9p^#}|hkLr0tmxu#C?m5oVR*(WeNV-0K^<4H_A}!AHuAcTxQmW59hOxIp92V+w zq<`qHuY=*RiTIyIjPT+6x%+jP80&w=3@fO)hI``JosQarnce$^y3Een+)%eOa=h9* zd=pyU5o~}Y?_NSH_~B+P8JCrpd^`+aqxN?>v&QT3tMO1?o4Pt@Xaq`l`d~AE6Qvqz z?|Lt&4#JEBb1^rLsrKMFT zXCArwQMPyQN0@%HRDNh)`6^@H=tJ&k!(Gv?(?EHLmt`n2Dt!l2nUMEie?HS=wp(0G zpuE2#$DC22^7NU^QR;~M0gL)!XJ|KLXqe2iO%dt(U*(Jz3^|XFCUI~=d+R*cau%-I zb_e5n1_d&=Qw_ms^G(ui6vcmjx0&eMSvCu|&Rj&)i#X1cZ4yMC*VyK5f4!1;ImeSA z{&|E~JuTB9?v6tPb2vw~gx_G;gSDW5tg7kDazt-)Qvc7Fhp%?V@Wv+(W$v!Ydo)Y9 zBK%>ki`N}ADxV_k@3&Q(MO{}nhH*Oh9P>rqycp{KeT{*t%4~xrSasNWt)@JpYF9TS z7o!wx2h~MU5=7caQfG!5+`NG-VpBT$aQoY?kZ*|4*0$m5AgjhXU6z@J!%vRsp~mqH zhB)#S15b*@t=_chc6N``uDm2cuvS@g4Tp(~QB=>S%)Iafuz)wn=-Mz&27YWpu@0?a z6?Z|Nrp|W?@zFG&%+bW6(qh#YY&!$%-G2 z#>Vi$7z~0Q(DMG$8BoamL}%z($i!fd5UN+R_8}`iyB0{xSMcPG7Kd1WgmETWu@1dq z6<DMXHHvA`*FEx`}9eU7b-lJdiF? zgG~415qB|N;-ht5T~pYU{O=<0l$LxG*PW6P!lCb^qh%@}OOou8aJ8D#)O{4rdPKu; zSz_W>URJTTdWWm00_wU@%?-*%juv08?!5KFtI9S({P{-^U)<;*oxktQp0DS0od2(Q z;vc_ytnqEJE4%dPAuBVS(WfuEsnT``++^<#Uqv0jmgOT`V5rOjXj;z_Mv`Fs#mm+$rkKEwe(|$J+KKa3g&%ubOqYx0_FTvU$C9=bN($% zSg3hLxGwqVT>re|=aeT@>mKM~=Ddk9!>EI2%X8J`Gnzo~hIRr{ErY}tzwNNxeKXY9 zFt0ZZsR#GC26KiI{<+cq4$rDVMAD;iq?@*HQ-R#65X^!6Q;u`uu?AsKgU(;S6V5oK zvg}Ny^UTfF4>FwA?lU(eP1^#OLzh!0SnpZ#r$`|WgFej76=iv8w;O)U+8!42SFw4< zExoG}s^8XtuJqc)ZhdrFBo?Z?V5OWqt%z-1uQF_GI?LYn+i{7UUewc=3!EDBXqXJ| z4d!1YvMe2|zd*6cDDCw!rayrfgdU88sdMPVivn^TMt^%0*?;A++M6!EI!oiUl2e_u zM^MCd9{M9Mdb+7Fnw)WluV4&TXJ3++kf=|=C^cV1JeF`Urmj;&`tPctQ$+sn`s&Un zT(a$+psL%uu;d&CF4a)oNYbSu+SB{z#e@fAx;jP4XnkRHjs-%YO)V^jY+Z`ryjUwo zNOkt6-LQc?EXxm}a2(--C0nPv{pxIJ8g{+T3Z*-(U%Ik2Y0WyhL?}%KvR6p{UH8+_iOtMOVzNl8xO zwoHlT)Fx(g7SAcx4g=3=wa^xQ!*)^vr1zeLI`Ed$ZSz3sBn5BPCyb3t4Poqb4Os=Y zPkf~zse8A+Z+X1t4*yS0z2$B9_g3@tlUb*~OVNMv=FolhqX*)&RkrKD@OposP4Jkz zUJNa5+{$ju3LVch3ct-$nR0ykGv;NsIfIRF&F^($%Waq0dbwvl6ZbStEVNCOOOxi^ zJsIy>WQo1`^|W5$VU6oxp6{Q4+4sKI?0WS!+rIto`E`G^t;UwuWH5=6_GzMKN#vvh_2i-CxV0j{n%*A04GH^S`07vw;yhl-wLxJTmMBjy3Si*L>aqO zl#bSyhGiiU3d<2bQ!jA8=ttN23-r`<(q5oNiEM?esO6^$eJ>JB@stKx( zM0^JyVpdbCUtEgXeWhDXq1v@Jkuh~Ol$q-=`m;33<}k)fkurFKl){Fp>i-EDxn&o- z=TYc4bK7p9-tJ9#5HagzTQCd3dugfqq3N05AD_tEW$-)Owaic@Z^y6Ru17sG{bM`M zvuLKBmBPMQBdl27T8<0qRd-m8zDQ{v(oVtNk&S!dYbxC0Gvz`lgiVrYoS?Ha>%qj$ zlC#FmRv5@cw#GV+<5m7TWS-&eAIh71-vb7WXaMADIL zGwv0hy{GDL$b-%l3ES%cO0Wt+#R;a?<#@#EjIeD}G)K4=@Hn zf#=)>5?VUTl#>4Y)sgWM)^k4}bPup8^anJlYcq=luMI9PC!qVeU@7Vdq1Xsx4zl8V zbcRjvf@)ncp_~X~EwbXwJijFehTZ{TSc*B9SVM%dA6YSu&XAd}!0VolLpr)&5|-kG z5SolIt|u!FS!~{u(AQbcME5J8)BF)ahicY4WW`(bhUZa@O~fAijnt&N6cGg^h*3h= zdN{iFjt-U!9(O%apHx>kqJRl8N{QZ#MAxe8U}117+QfKLUA>3`{@}~( zUD||377DAlb1wONF9~<+A-nuzWkX$j&M__rheJhfLjG)1k0O@C6ZRH?SXqkyQa_p7m{;eeRU79@^pBb!2oC(d zxH+J7ayzxnu>axCGY-3RxAZp*rYh2=nZaKlI{g*2TV4o$WAbEzdHhanTt$|?&}^Qm z(5%a4@zFDB-F5ST=Fx%j%F)g7>ki}L^l>J`8!6+~M>3{1yZL3F%~@3|xS?(fl&S0l zzP_Sr=odok{v=&@^Vd?rP3h0FSV9J4eC4U8#KQeu!r;UD8h@P(f5W%Q+3P`v_G##{ zF~bdZ&%E@mX};s=58j0AJkg;%Z!Xo%{qN!Koa+De<0#1^4le)x<`ycf@lui zmd=_{jf4VQ#HcvTDII-uPv<=!+=@Q2o=g|>>7ucFq+wd^AF=4^Ha_8WyS;5YaVtBz zP5onSptq#X1PPzA()kMQ-4^?-&Ob?&7jH*T63;n$47TV~Z6NRS4y4aR4`cADU{gzb z2GpiJ^N1*Ab6QnD#~C#P1>g->AX*m2o6|geGNBbv%O=z`66IcrDiPJkc7$EpJzHM= zDQaT4fAitu;MgH&rD|41!z3nU3fkGhVT1d(q1qO_3buEC$SpiSJbcU=vKE|xd=8UW zQM$wUoYUDizRb!6%Z+WS`bO+A%%6T_o<>Jjdm+BWX-ggU@Rrs}IR5>?Y3Pn*#qL98 ziXHFRpHsiSa|id~=FC@`HfZT0BPL54}=) zP)}PgR2?_&yY?n9Z2yVog#H45AG)6TW^=d;y z5IUnD4l~Z`XDL?jtxniDN@?A9oTxX;n%&J+kX4ky;}VsGHh;%~P8 zcQ-D*y!w6#6RP9FWBes7%Y^1~ago!vqBLkrMG9Z3z-w&aMf_b=#JMsE`}q#8W^&Sa zm?kX_Z z6$d@9BaCm76|2!1=E4iYb#-1Pp~(f%Ooj-dPZ7os#Xr1edWaCJb$3MKTT{Z>&n8*) ztt4BwxC1+~rq6e$5q>517_%@8*hfX?qTTmSnc_>qi$7jPrR-<%#MN}m-*lV| zb(S$O$N3_zD zjfSNX6@C=r6@7kx8u=DJd2Y9Dcl#DeMwmV_PCjVf9jOW2CPbL@V8Q+eNo~Peo{mLEM0;e-F6v0%mFLRQt zDHma`#WM|s8MdfjTDDOAST{k{PCr3xBt@!e4dFoRaret|Z(*AFO(8ZfRz5Zs@v(r$ zOQPks-BHQfrL!Y!!v6Z+O3>9oGX9#CZ#53@lVqQ|43))=XIgQ%tH=h2pNGu+SL6Gv z_8ei1K7aDq<*?9{>POmUU`AzQinNVLHYTL}ap>Uj59{}vV*SsMGyO$Pfm6EPE~nmu z+p0*Xom)!zO&5nMgKgrhQuTN}>C=`T7Y?64c42EBL%3eYs`IJ~%0nC8?u?uzsdqU- zLFqXvlu|ACM2dbW)IXP)-ot7+yh#%e{*T!%{`u&>d}!92CMC8-@3a8M9RAh8v(}ig z{Xov0xM6>%1VLzlcb};K#~ZOmWJ1Z$Jl~QpV~nLSZ!&U)6g*n*j*op1ny5-E38+ys ziGGhSNn{8$DI^apx$7ucE@X$1X#cSowC_;a*psGcA<>SjpV{}~w3<2^3B5a7i&|aH zpNoei*hc!~y`9?vdxeC{ch%2N6HPqy+v9$sMa&iaQp3I`c=1E6vIl;+;MkZ z*Y&ChzH%kqw?9f?j}=9XK7oaM$auEgo)9cGxo)2RBLPi$z4!k0^!KbV329k%duQb9 z-d{5}$og(p%?ZkCBP4VX61oV9y9fzAN}<(AvZarLRHB%YWa2cJL1 zMr4SAF5)+!i_8vmef!r{B{kAR?gX@cHRO+HiXPs?UN2Mm@HfMu_%|%QgDh@j$lrz; z2nA0288zhGGfNKw5-)mf)|agE=nVNOmYJU~4hu{k_Wph`Gc$5BPb^krJpPF45U+Rt#ogv*oI#irvoQRwG!h3nI2*;6H&tq z9uA$9H@{XMk`(l7IeY)OVpzyiCr_zX5>g}&EL?gnOnbz=C#Ke`#WYXd% z+y(v7Xd!{(e7t$y1yUBCyKO1@TyAzT*5aUC^uguNRY_sIW0v4Enzdf&R$5 z8=p8UK^GO!+S7nWW30=5j0f7B8L#iJ;1Xat`ftnuFxZFz2LFUKg$=!KJ@(_K#pU>* zrhGhPqJ3F(L@n2Pc=*7*=w3W$h@#_AqJ=xMQ06X+l6xyNyjxOhVrlv{v3%0;Gy9Y< z9_l3ny!^3eh=6yin9t90Vyu|ezAEpmYrw^It)Te3!DIN-F%qJvk3#|%C>JO=D7fGu z)Mu3q@hDG2>GY_?DX7-U{>#UU)=SmS+sDTb7HgfvD98No-~Ms)$?YGH8t=c}WqbBc z`lQ&_Z`LJJe&Hphx898#D|HX~Zq=e39-cjG{v0qVWZNf({4#o`PdwK9=u7p;<*Vhl z&R=6b=|-kZu|NKhQ+k(Hy)^c3o%gM%J1p7Ar_Lf{=&*#{2iLGXvKc6q2h}jPB2H6g8JJh#D zp47KFrjlKNw~HrkPMl4u{r1xxZaE8Tu7V4lc@HxRO)>{1+=~|;Z{GXT<>}@YHS@$Z zO6%TjHYDny+-N|lN9|PLag`V2@jfo&7k_4Isew{PzDl579`?r+a-(Ko@!slF8p*7L zSaI_x;tWxZI&mvX*JsVYN=nwf+PpprBZ=;r#X8DZ39ps6XE&8$v9Y)f)`rY+S%bF! zFjJwHp%)1?8;TUCrMvjn2chn7blo;p^+G#>GqHEljz31>_3kBvJblm6!g?Q-a@KtW zcT8R{&Wl}N|9T~9;8(bkpTol2f$0x#1YLSLk}+r=t{m~AVG4E8)79=|JD-W30Xv_i z6R4vW%E}zu8)St$Kz?FBz~4XflwbJOj*%%Qa@?hCsD7yqcKrAl+=LGFT00>9LkPnP z^#v;q^C6kiSRnan-8kqXIu5!@T)ZzPo!3WH*xhlYA={Mu>kPxE69=hzcxizVkuF_o zL1{b%x#1!zolDsq-_IqDFQNci3TVs{7Vl&FK*O8NaIvEUG{ym)>i_iIEa*m&y!>^h z$_Mi|E8g$u2BQuC9P2>iE2ZHg#P8u{1+3N3ERZr0Akqa5c`85@4=h%7Wv@5!=*2;b zfYC+|u`>h)(!jtmomWvR3$)Fv#FItih?$YX zZWP}gXIw)anCwk5ZJo*3N7l|bcZPy)D6sC=S;MEoZio*wY(!!HD3Hnn5ovr&G4RUN zb;i+1{_{%ubw(ot8Zvw!(oeWFzVtmHmj~Tqj}uv-^g>>%z&MyRe&BAe3w81SE@c6W zhBFRh3No*82Gh0yl(HqDxVoU>_&?kh&bUfhK*ZY2M4Io{4?r_CX4R5-<^Ak;eY?cG3me66vNsv>uhp?u26ks2N-CHn|- za<4A~XC@{mbW27+c?W6?E@Fe$mlya}JA8hi`Z%W{K2ODgevwxOawVo~naN4DLthdU z94&3!p1N>ar6$pfh_0M8m$?L6R$sEXnBad6C5HM~y2OW#i^!{&ntRuY+4s#hevmd8 zpG<9aurWVm5c@Q^8!%__yls@R5i&H}`x5Au3%(G4CF?fIX#RA9Axm1jG$!=1$jKn7 z+x~=h8E^A&b@I_82a$36cZEJXf)f(UFGU=zp$g&EnV*I`Gxk>J8OygAWRAv6lxEZ{ zox07M`-S_RODQV~jiC85-|dOH4Mtzf&AHE!gTv9;}<=GHzTrY6Tz56Iu7Z zX#KEQAt1KZXwyld)O;r}S6fy<>}-vA`4#8vheuaHgIi5jfa%&bekKASgg}UZ5QpM)SBg7H6qLQ1kstbA z5idPapI-_Gk_Y4qkiS5#Y0NKiN631?xIMhY@4h&ck&8AY&%Ef;%`}{sdXc14*x6)e z;g%h1LY}GDsap#=!;=CEJDG{}xl^w>UXhD7C(msAjH0W%A$QrOJ42fJXkJa!i}1dv z4;Pan$XN*;%K>uX1i1iVy>g9T_9~%1l8`iWhVr^x6O7Bl%Sdg0srz|n-{95dG*&`% z24Og#EIF4f1>t?%)?6(VbxCss+Baqi+Wj{3@Sg&ymX zPLHgFTkzT=5`S<8*uCto%TXREFtE8@63QuRuoyzEr$dRAkUW>?^fC!%NV=hR?eW}3 z;eYO>hGVVDrwqsOA=}c5bnl#2$5`H5t%|1mNtK2VRd~y)7kiS2K`L-0=ocwX4Cg!6v>glMls`;Fu-4~?G;2>39 zU64riM7EW_WzkpBtf$YOy3KBMtZ%IEJ_{ueDLO0rsZx`or6zuy~+NfDGIvcrnaKp zJh5XvHPH~iKDF|r$gRaOu44A(>eNZcW!a|3+MAQoDgs}2D8zaF*P_epq>+KS0?ARs z260)-kx&`#OHNRd+uYD_x8aXBxwv85&vS8YFbdRpeaf~Yu7^Dk!D5x_?wMtc) zMwExTLr>RbD~s!(x!qd?TE^AXxX@g`{{rY|=Q(?vqNFN6U1Z3%DYE-Hvo2JW+ege? z+kPCSFQt>zb%Tf>)l5J3D<_O7#6tB%4fY3Hkjqn3$x|Q^M@!ZE$WDVmZ2IO%=YXu1 z)Sg8ATguiCL{*0Tr#=dj|<`DIO8L_IgBOh*?lby0KiJN0TT8 z(`-5)_3*2p(vYv90YVFe9tZ;vCLqi}Sb?wu;pCdUj-_mhfBr}h&=>$Q0%8Wl5{L~D zJ0Omqn@mUdP@}AQY?Ew2*nw~W;Y7P!e-OWUy|?bQK+iD{>1cr-Um$frj)6$W03Apj zkmDF^tP3MB2jap6u1vksZ`#!>?6@W+VC&4i(s95RNF9)4Aky(b2T}**7>INN(1Fwe zIZp8EQ@#=!HAC`zhJv(@=J^c$_2ot}u6|Es2NJ z=G0)20}QHy!Ixl=2KL2M3{f|5N!qI%jSeB}Z6gC#^uS60SQ&kK(`a_*$4n)#bcFSL zVi3uTbKmhYyp%yFqprm7;fH@a=#AKJ{UBmoA*E2K`K$W-WA?SomwvD16#7ZSX+TvBzfD^-HrgH}}k{l@@$!%xYnMiwuN z2f%N-7yUn6PHI*P&VzIbspyMVeOFiyoZZ)_A&eSlrUm`;umfBU5*K&OIr`YkFc9y* zenWO7%Kv2Sl!DseaC3LR{XA#Z9vT){M78V9_`SDSNxXgBdu4WLJna9l^wj}TJ@3~b z{XrT5X#_;1M7ohi1f&HCr5ovH1r`BmkdRtw1Ob(9X{EcnyBl`zJA8ldA9wbdGv|q! zJFD*Qonu|&MC#KEWa>l>tSaVMIz4pwdBvCG|EXmBdDZ<^MW=HHKb8I9)%Uyxv7+C{ z#tYdm--c;=H`8fJT3o|EZf&sz4=`4XUX-Eh5&GcN_za5uc>v#dSo8Ui7DJCT(`3=M)fL}R!^=3Z_`elo^2-F# z*t%!)2#)(J$lC5Zg8&%QcLpIaw(kt06=aW6f@JJH-g!hZF+fQJ4WnwDJ07q{eL2?Y zX3krU(>zxC9x0^x@>P}j(!#u{LCN6t#h5{cD_(roG%M&>X6V+hBFt#V-j@5XR2}<- z*j(At(vuevmBklzT~?emKLO$pzP{Z%u)L+Si*VeCu+K^zJ5UQs>cVpacJ6itewS7r zIA8F+IeIKEZoQKGgFJ6klff8ShmL4q7S}{(Wr&TF}1+19U)J|di7P033dfCpYD3kOpMC$+z#T`!y-ETx(vd7LUke?V@d zu;U{Wq9RqP(M;`B>1M!_f z|63lYV_KMQ2Z~+P7V9k7S!f%1`@biR6tRrx`onK}R^!huuEdeB4_5ttvOxM`yV>T0 zcJ<_9X3G)~p~y6LflN9_Aa?(6OMTxKu?<>EVGv42I(T5@|BFF*cVDaXA#dQD_40BB z!@MXR@Z9k_3_Q1d0Ob{+64VnJA3KUg)8Cq>UT^D@tspK9)rPqiLe+{IX{vEABO9}w zVh$d%9p#+w_X=@%|Jy&5KYKp7M)6eudz^fMyM~ zN}*-&wB+3b>i#Ho;(N^AbF=%dnNURk$⋘MY7B(kDpNCaGsH~1=v&bK#fELA~DkB zLc!pug+}OLX^(XSx?c#D$<(0yB^4ozx5=43Qk&8d%0rGUNH+J2qo|k0V{>bXOCz`P z7*KXyJKSTaV4mtwodMc=;lUIGsm<&hb0G8PY}RX3<8*q zZTsT9{CBGGE?50cEBTsC#6x;72<$GCkzylBv&Of>*B2uf;cAIY@1P=BjOPi0@&XgA z=ns@2j+N)Y}Vl zdvmt~kWLenkO`gzn|W+Av-Y=c{*x=c=v-y$BR$9X!Vu@9QGs@tSbR`r0D4fF)6U0% zNvUzm<*q+3i*AMgVk16qT1}U(KD~J4pNT@=$e$|5oSDpPfv?TH!9E(WK7%SP_U7|l ze9VLSH^FDNjWLK}K8?@eU(CncJk*Sw9C$44FXPpMmLp^(j9jo5616aO8fk@2PNAhn zk&&cyXhah#d1NXRlykcE4d!`34jy{#IfH!~X^wcq{=KtPZvC}=F$FrJ6!z%<0*}EA zwoQr;9-a@ZiH)5eQ;VU zPvuCaZiF_z;;HgVH7PyOYc?2tdE%2=Ao@N%^zlgg>+x3uMJhTE zdh6JnoQ$6tDUDwb}8TXH~p378Eql+klM_fM+xdV41G-^%|c_IMy21OXTzhOL3w(KdgyTz_6PE&u9Ycs@KZ7|bZRBI8%wyjI|UkVaGQyWLy z*T&J|Vsi8`B;uOZ)1~Tw_}aHuRVq-~?4>*MeqVb;FHOi+l#s!Eo8LESedi})EF+X~ zWb+og=q#`$_0LO_;`&j$LOhqD!e9<_MeWeG zBvB5(ztHLH3$c^NTIjyi*+=)d^}yibLL5~)3;NlT?>2v>iB4uLym!exz7Bf{{>zDrY-52ZoFRRXMjV-s}E4h^7PetUcYdTv(=S4 z82++KD?MjqVJD8K#Z@MSv*6E;Ke8E&=n0ONA#Dk)eTA$16wX4)n0%k<->2AIS*2qp z7Ce&pNL=NYa27V1cygwHpJQ|7m5v{=gpwj?E3N5=~}cQjU!^ zS5nesVG$vXXIY9gOoFqh%EYrVDSwGIf2pL&%2G=jZ;q#2CCzOl6VJn>oDysPN=frE ziwIeKE}rr@oW()g+i)Jv;w%#{!laxLyGpnk&&D!^QG1G~jDnk^!_6_^=2&oZ9Jo0i z+#DZnP5?J2f}4}T&B@^A4}O|EQo_w2!p&)<9qHia3~+NMxH&W2oE2`)1~+Gin{&X; zx!~qJaC2U`IUn3y0B$Y>Hy44Mi%C05z|Ezk9bdrBW#Q)XaC1etxiZ{b1#Yfds9Y;S z5-(2@uSycHLlSR95^q5g?|@$GP7?2rUK>IZ4<6=8B#F-;iO(a6FCmGq%#vI=S5Tr* zR1#FQwtj$<##|$FlaK}bm zR?uCtB-bq_sA=OM{0cBHzX*idk(bd+Z6SeW5>z|DWQ-qQOqWAnzJ6Ma&DfNLo!a~@$6DFZcUid<% zKvcu+*=6>_z81<*K}?h9zj30gE1X3>XbDLjTC6<}=5cz1`0#86hO;pE9xu9@*YtT!Z5@f9 zRz$#J8opeh(|1w4n}uU>G{3h^O^Y*X-hocAX>`9X3fL}eBRqAne%&kRz*q~@Z{TOc zqHh82;>|ro-~p6R9PRmS-sVHZzQAS6@=gnp&5y-UK1BObbfI7{L+u&cCGUIg%U=>B zEins|a3tc=0r&Esk?SAzsIg!K${q9aE4}2Rm7*tx6y>+JqyA7q>}sD;jOM|bdzT%C ztSvmX0_{iK5-w=X@j0}YiER@tWs-mI2RJm+ox_lebcZw}r9lgZ9%#4Ye=-)U**Gzp zN1T~1=TJA!4ZZ?hfit2he-0@B!-2h)AiT{&zM(zP2&6hx)NB32AmxOCny8{!R9Dl% zf{6}1xSdKuqdm9^OJC$!_X=#j?{gnz`$qC`qOW|#3ome>&w5tZ$6BVf&=cFx?FF)f zyhG?C$NmrO>H$9kjM)panzoX&#jg}ZXr8ltWV1HIBU-t@3r

nsLfe{fO&1=1%|-_JhSe@9*!LjqKV$c9xkKAx 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 V5-{>7NBo>pO&&Kgl`>J0`X7o!tu+7u diff --git a/setup.py b/setup.py index 643d77301..c152a306b 100644 --- a/setup.py +++ b/setup.py @@ -77,8 +77,6 @@ packages = ['supybot', 'supybot.plugins.Dict.local', 'supybot.plugins.Math.local', 'supybot.plugins.RSS.local', - 'supybot.plugins.Time.local', - 'supybot.plugins.Time.local.dateutil', ] package_dir = {'supybot': 'src', @@ -88,9 +86,6 @@ package_dir = {'supybot': 'src', 'supybot.plugins.Dict.local': 'plugins/Dict/local', 'supybot.plugins.Math.local': 'plugins/Math/local', 'supybot.plugins.RSS.local': 'plugins/RSS/local', - 'supybot.plugins.Time.local': 'plugins/Time/local', - 'supybot.plugins.Time.local.dateutil': - 'plugins/Time/local/dateutil', } for plugin in plugins: @@ -138,7 +133,12 @@ setup( 'scripts/supybot-adduser', 'scripts/supybot-plugin-doc', 'scripts/supybot-plugin-create', - ] + ], + + install_requires=[ + # Time plugin + 'python-dateutil <2.0,>=1.3', + ], ) From b1552ced1166686b5e59e34039efb432cede5ee6 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 17 Sep 2012 21:31:49 -0400 Subject: [PATCH 06/67] Ignore setuptools directories Signed-off-by: James McCoy --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b41eb555e..189ab2749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ backup build +dist +supybot.egg-info test-data test-conf test-logs From cef93a6cfdc3d858595cc9d32dfa8d237b0dd5c1 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 17 Sep 2012 22:12:11 -0400 Subject: [PATCH 07/67] Use relative imports for all packages under the supybot namespace Signed-off-by: James McCoy --- plugins/__init__.py | 11 ++--------- src/__init__.py | 2 +- src/callbacks.py | 15 +++------------ src/cdb.py | 2 +- src/commands.py | 10 +--------- src/conf.py | 14 ++++---------- src/dbi.py | 5 ++--- src/drivers/Socket.py | 9 ++------- src/drivers/Twisted.py | 5 +---- src/drivers/__init__.py | 5 +---- src/ircdb.py | 12 ++---------- src/irclib.py | 15 ++++----------- src/ircmsgs.py | 6 ++---- src/ircutils.py | 2 +- src/log.py | 7 +------ src/plugin.py | 5 +---- src/questions.py | 3 +-- src/registry.py | 2 +- src/schedule.py | 4 +--- src/test.py | 14 ++------------ src/utils/__init__.py | 13 ++----------- src/utils/file.py | 5 ++--- src/utils/gen.py | 9 ++++----- src/utils/str.py | 4 ++-- src/utils/transaction.py | 4 +--- src/utils/web.py | 2 +- src/world.py | 6 +----- 27 files changed, 47 insertions(+), 144 deletions(-) diff --git a/plugins/__init__.py b/plugins/__init__.py index 081334842..eeba620be 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -41,15 +41,8 @@ import os.path import UserDict import threading -import supybot.log as log -import supybot.dbi as dbi -import supybot.conf as conf -import supybot.ircdb as ircdb -import supybot.utils as utils -import supybot.world as world -from supybot.commands import * -import supybot.ircutils as ircutils -import supybot.callbacks as callbacks +from .. import callbacks, conf, dbi, ircdb, ircutils, log, utils, world +from ..commands import * try: # We need to sweep away all that mx.* crap because our code doesn't account diff --git a/src/__init__.py b/src/__init__.py index e3ffdf162..8814d2ff5 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -32,7 +32,7 @@ import sys import os.path import dynamicScope -import supybot.utils as utils +from . import utils __builtins__['format'] = utils.str.format diff --git a/src/callbacks.py b/src/callbacks.py index f8ec9eab9..36ace9b4d 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -32,8 +32,6 @@ This module contains the basic callbacks for handling PRIVMSGs. """ -import supybot - import re import copy import time @@ -43,16 +41,9 @@ import inspect import operator from cStringIO import StringIO -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.world as world -import supybot.ircdb as ircdb -import supybot.irclib as irclib -import supybot.ircmsgs as ircmsgs -import supybot.ircutils as ircutils -import supybot.registry as registry -from supybot.utils.iter import any, all +from . import (conf, ircdb, irclib, ircmsgs, ircutils, log, registry, utils, + world) +from .utils.iter import any, all def _addressed(nick, msg, prefixChars=None, nicks=None, prefixStrings=None, whenAddressedByNick=None, diff --git a/src/cdb.py b/src/cdb.py index 408b80de8..6c5e3a1f1 100644 --- a/src/cdb.py +++ b/src/cdb.py @@ -38,7 +38,7 @@ import struct import os.path import cPickle as pickle -import supybot.utils as utils +from . import utils def hash(s): """DJB's hash function for CDB.""" diff --git a/src/commands.py b/src/commands.py index e38908a41..cfa1622d5 100644 --- a/src/commands.py +++ b/src/commands.py @@ -38,15 +38,7 @@ import getopt import inspect import threading -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.world as world -import supybot.ircdb as ircdb -import supybot.ircmsgs as ircmsgs -import supybot.ircutils as ircutils -import supybot.callbacks as callbacks - +from . import callbacks, conf, ircdb, ircmsgs, ircutils, log, utils, world ### # Non-arg wrappers -- these just change the behavior of a command without diff --git a/src/conf.py b/src/conf.py index e23efd009..4172aa92a 100644 --- a/src/conf.py +++ b/src/conf.py @@ -33,14 +33,8 @@ import sys import time import socket -import supybot.utils as utils -import supybot.registry as registry -import supybot.ircutils as ircutils - -### -# version: This should be pretty obvious. -### -from supybot.version import version +from . import ircutils, registry, utils +from .version import version ### # *** The following variables are affected by command-line options. They are @@ -249,7 +243,7 @@ class SpaceSeparatedSetOfChannels(registry.SpaceSeparatedListOf): List = ircutils.IrcSet Value = ValidChannel def join(self, channel): - import ircmsgs # Don't put this globally! It's recursive. + from . import ircmsgs # Don't put this globally! It's recursive. key = self.key.get(channel)() if key: return ircmsgs.join(channel, key) @@ -856,7 +850,7 @@ registerChannelValue(supybot.databases.plugins.channelSpecific.link, 'allow', class CDB(registry.Boolean): def connect(self, filename): - import supybot.cdb as cdb + from . import cdb basename = os.path.basename(filename) journalName = supybot.directories.data.tmp.dirize(basename+'.journal') return cdb.open(filename, 'c', diff --git a/src/dbi.py b/src/dbi.py index 94dce6355..8dbb2870c 100644 --- a/src/dbi.py +++ b/src/dbi.py @@ -35,9 +35,8 @@ import os import csv import math -import supybot.cdb as cdb -import supybot.utils as utils -from supybot.utils.iter import ilen +from . import cdb, utils +from .utils.iter import ilen class Error(Exception): """General error for this module.""" diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index a9d98459a..0382270d5 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -38,13 +38,8 @@ import time import select import socket -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.world as world -import supybot.drivers as drivers -import supybot.schedule as schedule -from supybot.utils.iter import imap +from .. import (conf, drivers, log, schedule, utils, world) +from ..utils.iter import imap try: import ssl diff --git a/src/drivers/Twisted.py b/src/drivers/Twisted.py index 6429022c4..3acabc07a 100644 --- a/src/drivers/Twisted.py +++ b/src/drivers/Twisted.py @@ -28,10 +28,7 @@ # POSSIBILITY OF SUCH DAMAGE. ### -import supybot.log as log -import supybot.conf as conf -import supybot.drivers as drivers -import supybot.ircmsgs as ircmsgs +from .. import conf, drivers, ircmsgs, log from twisted.names import client from twisted.internet import reactor, error diff --git a/src/drivers/__init__.py b/src/drivers/__init__.py index 302222ad7..b89d63fa4 100644 --- a/src/drivers/__init__.py +++ b/src/drivers/__init__.py @@ -36,10 +36,7 @@ import sys import time import socket -import supybot.conf as conf -import supybot.utils as utils -import supybot.log as supylog -import supybot.ircmsgs as ircmsgs +from .. import conf, ircmsgs, log as supylog, utils _drivers = {} _deadDrivers = [] diff --git a/src/ircdb.py b/src/ircdb.py index 1e51b879e..403a09df2 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -28,20 +28,12 @@ # POSSIBILITY OF SUCH DAMAGE. ### -from __future__ import division - import os import time import operator -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.world as world -import supybot.ircutils as ircutils -import supybot.registry as registry -import supybot.unpreserve as unpreserve -from utils.iter import imap, ilen, ifilter +from . import conf, ircutils, log, registry, unpreserve, utils, world +from .utils.iter import imap, ilen, ifilter def isCapability(capability): return len(capability.split(None, 1)) == 1 diff --git a/src/irclib.py b/src/irclib.py index 95f10dff4..27c373c1a 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -32,17 +32,10 @@ import copy import time import random -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.world as world -import supybot.ircdb as ircdb -import supybot.ircmsgs as ircmsgs -import supybot.ircutils as ircutils - -from utils.str import rsplit -from utils.iter import imap, chain, cycle -from utils.structures import queue, smallqueue, RingBuffer +from . import conf, ircdb, ircmsgs, ircutils, log, utils, world +from .utils.str import rsplit +from .utils.iter import imap, chain, cycle +from .utils.structures import queue, smallqueue, RingBuffer ### # The base class for a callback to be registered with an Irc object. Shows diff --git a/src/ircmsgs.py b/src/ircmsgs.py index 213247787..40b2e1c8b 100644 --- a/src/ircmsgs.py +++ b/src/ircmsgs.py @@ -38,10 +38,8 @@ object (which, as you'll read later, is quite...full-featured :)) import re import time -import supybot.conf as conf -import supybot.utils as utils -from supybot.utils.iter import all -import supybot.ircutils as ircutils +from . import conf, ircutils, utils +from .utils.iter import all ### # IrcMsg class -- used for representing IRC messages acquired from a network. diff --git a/src/ircutils.py b/src/ircutils.py index 9f07f900e..c107a16d7 100644 --- a/src/ircutils.py +++ b/src/ircutils.py @@ -42,7 +42,7 @@ import string import textwrap from cStringIO import StringIO as sio -import supybot.utils as utils +from . import utils def debug(s, *args): """Prints a debug string. Most likely replaced by our logging debug.""" diff --git a/src/log.py b/src/log.py index 00c3559d3..876b62c8e 100644 --- a/src/log.py +++ b/src/log.py @@ -37,12 +37,7 @@ import operator import textwrap import traceback -import supybot.ansi as ansi -import supybot.conf as conf -import supybot.utils as utils -import supybot.registry as registry - -import supybot.ircutils as ircutils +from . import ansi, conf, ircutils, registry, utils deadlyExceptions = [KeyboardInterrupt, SystemExit] diff --git a/src/plugin.py b/src/plugin.py index 25cbe67a7..2eb96b2ae 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -33,10 +33,7 @@ import imp import os.path import linecache -import supybot.log as log -import supybot.conf as conf -import supybot.registry as registry -import supybot.callbacks as callbacks +from . import callbacks, conf, log, registry installDir = os.path.dirname(sys.modules[__name__].__file__) _pluginsDir = os.path.join(installDir, 'plugins') diff --git a/src/questions.py b/src/questions.py index 34ebfcd93..5cee897d6 100644 --- a/src/questions.py +++ b/src/questions.py @@ -35,8 +35,7 @@ import sys import textwrap from getpass import getpass as getPass -import supybot.ansi as ansi -import supybot.utils as utils +from . import ansi, utils useBold = False diff --git a/src/registry.py b/src/registry.py index ca9568daa..7108ef661 100644 --- a/src/registry.py +++ b/src/registry.py @@ -34,7 +34,7 @@ import time import string import textwrap -import supybot.utils as utils +from . import utils def error(s): """Replace me with something better from another module!""" diff --git a/src/schedule.py b/src/schedule.py index 7aa9ecfe6..8921adaf7 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -36,9 +36,7 @@ import time import heapq from threading import Lock -import supybot.log as log -import supybot.world as world -import supybot.drivers as drivers +from . import drivers, log, world class mytuple(tuple): def __cmp__(self, other): diff --git a/src/test.py b/src/test.py index b7488f0d2..fdb1781e5 100644 --- a/src/test.py +++ b/src/test.py @@ -37,18 +37,8 @@ import shutil import unittest import threading -import supybot.log as log -import supybot.conf as conf -import supybot.utils as utils -import supybot.ircdb as ircdb -import supybot.world as world -import supybot.irclib as irclib -import supybot.plugin as plugin -import supybot.drivers as drivers -import supybot.ircmsgs as ircmsgs -import supybot.registry as registry -import supybot.ircutils as ircutils -import supybot.callbacks as callbacks +from . import (callbacks, conf, drivers, ircdb, irclib, ircmsgs, ircutils, log, + plugin, registry, utils, world) network = True diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 76e0d6cf1..db13d780e 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -96,16 +96,7 @@ if sys.version_info < (2, 4, 0): # These imports need to happen below the block above, so things get put into # __builtins__ appropriately. -from gen import * -import net -import seq -import str -import web -import file -import iter -import crypt -import error -import python -import transaction +from .gen import * +from . import crypt, error, file, iter, net, python, seq, str, transaction, web # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/src/utils/file.py b/src/utils/file.py index a59b2e3f7..86ea44254 100644 --- a/src/utils/file.py +++ b/src/utils/file.py @@ -34,9 +34,8 @@ import random import shutil import os.path -from iter import ifilter - -import crypt +from . import crypt +from .iter import ifilter def contents(filename): return file(filename).read() diff --git a/src/utils/gen.py b/src/utils/gen.py index e071ff819..180d8971e 100644 --- a/src/utils/gen.py +++ b/src/utils/gen.py @@ -38,11 +38,10 @@ import textwrap import UserDict import traceback -from str import format -from file import mktemp -from iter import imap, all - -import crypt +from . import crypt +from .str import format +from .file import mktemp +from .iter import imap, all def abbrev(strings, d=None): """Returns a dictionary mapping unambiguous abbreviations to full forms.""" diff --git a/src/utils/str.py b/src/utils/str.py index 9fb4223bc..02ca65a6c 100644 --- a/src/utils/str.py +++ b/src/utils/str.py @@ -38,8 +38,8 @@ import sys import string import textwrap -from iter import all, any -from structures import TwoWayDictionary +from .iter import all, any +from .structures import TwoWayDictionary curry = new.instancemethod chars = string.maketrans('', '') diff --git a/src/utils/transaction.py b/src/utils/transaction.py index 5d4caeacf..b3b730958 100644 --- a/src/utils/transaction.py +++ b/src/utils/transaction.py @@ -35,9 +35,7 @@ import os import shutil import os.path -import error -import python -import file as File +from . import error, file as File, python # 'txn' is used as an abbreviation for 'transaction' in the following source. diff --git a/src/utils/web.py b/src/utils/web.py index ae3cf38b8..cbd0ecc73 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -43,7 +43,7 @@ try: except AttributeError: pass -from str import normalizeWhitespace +from .str import normalizeWhitespace Request = urllib2.Request urlquote = urllib.quote diff --git a/src/world.py b/src/world.py index 23ec3789e..594b6e994 100644 --- a/src/world.py +++ b/src/world.py @@ -43,11 +43,7 @@ if sys.version_info >= (2, 5, 0): else: import sre -import supybot.log as log -import supybot.conf as conf -import supybot.drivers as drivers -import supybot.ircutils as ircutils -import supybot.registry as registry +from . import conf, drivers, ircutils, log, registry startedAt = time.time() # Just in case it doesn't get set later. From 198688eab4e9abd901370e992a7b8806b44d889e Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 17 Sep 2012 22:15:52 -0400 Subject: [PATCH 08/67] Import external dateutil module Signed-off-by: James McCoy --- plugins/Time/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Time/plugin.py b/plugins/Time/plugin.py index 21cddc28f..38a5aec4f 100644 --- a/plugins/Time/plugin.py +++ b/plugins/Time/plugin.py @@ -30,13 +30,13 @@ import time TIME = time # For later use. +from dateutil import parser + import supybot.conf as conf import supybot.utils as utils from supybot.commands import * import supybot.callbacks as callbacks -parser = utils.python.universalImport('dateutil.parser', 'local.dateutil.parser') - def parse(s): todo = [] s = s.replace('noon', '12:00') From b8b79d063d8b2dd643bde4be08e67983dcb6fc26 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Mon, 3 Sep 2012 14:07:23 +0200 Subject: [PATCH 09/67] Admin: Change message 'It's banned me' into 'I am banned.'. Closes GH-329. Signed-off-by: James McCoy --- plugins/Admin/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Admin/plugin.py b/plugins/Admin/plugin.py index 149d08235..3df4279a0 100644 --- a/plugins/Admin/plugin.py +++ b/plugins/Admin/plugin.py @@ -81,7 +81,7 @@ class Admin(callbacks.Plugin): try: channel = msg.args[1] (irc, msg) = self.joins.pop(channel) - irc.error('Cannot join %s, it\'s banned me.' % channel) + irc.error('Cannot join %s, I am banned.' % channel) except KeyError: self.log.debug('Got 474 without Admin.join being called.') From 0b81b170cae9496364f601b044ce90e94904d628 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Thu, 4 Oct 2012 21:41:00 -0400 Subject: [PATCH 10/67] Indicate supybot.networks.$network.servers/channels are space-separated lists in their help Signed-off-by: James McCoy --- src/conf.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/conf.py b/src/conf.py index 4172aa92a..b8506e5df 100644 --- a/src/conf.py +++ b/src/conf.py @@ -257,11 +257,12 @@ def registerNetwork(name, password='', ssl=False): technically passwords are server-specific and not network-specific, but this is the best we can do right now.""" % name, private=True)) registryServers = registerGlobalValue(network, 'servers', Servers([], - """Determines what servers the bot will connect to for %s. Each will - be tried in order, wrapping back to the first when the cycle is - completed.""" % name)) + """Space-separated list of servers the bot will connect to for %s. + Each will be tried in order, wrapping back to the first when the cycle + is completed.""" % name)) registerGlobalValue(network, 'channels', SpaceSeparatedSetOfChannels([], - """Determines what channels the bot will join only on %s.""" % name)) + """Space-separated list of channels the bot will join only on %s.""" + % name)) registerGlobalValue(network, 'ssl', registry.Boolean(ssl, """Determines whether the bot will attempt to connect with SSL sockets to %s.""" % name)) From 450be69710842601091bca135ad1093b13be58c6 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sun, 14 Oct 2012 09:55:04 -0400 Subject: [PATCH 11/67] Bootstrap setuptools if it isn't available. Signed-off-by: James McCoy --- distribute_setup.py | 515 ++++++++++++++++++++++++++++++++++++++++++++ setup.py | 30 +-- 2 files changed, 525 insertions(+), 20 deletions(-) create mode 100644 distribute_setup.py diff --git a/distribute_setup.py b/distribute_setup.py new file mode 100644 index 000000000..8f5b0637b --- /dev/null +++ b/distribute_setup.py @@ -0,0 +1,515 @@ +#!python +"""Bootstrap distribute installation + +If you want to use setuptools in your package's setup.py, just include this +file in the same directory with it, and add this to the top of your setup.py:: + + from distribute_setup import use_setuptools + use_setuptools() + +If you want to require a specific version of setuptools, set a download +mirror, or use an alternate download directory, you can do so by supplying +the appropriate options to ``use_setuptools()``. + +This file can also be run as a script to install or upgrade setuptools. +""" +import os +import sys +import time +import fnmatch +import tempfile +import tarfile +from distutils import log + +try: + from site import USER_SITE +except ImportError: + USER_SITE = None + +try: + import subprocess + + def _python_cmd(*args): + args = (sys.executable,) + args + return subprocess.call(args) == 0 + +except ImportError: + # will be used for python 2.3 + def _python_cmd(*args): + args = (sys.executable,) + args + # quoting arguments if windows + if sys.platform == 'win32': + def quote(arg): + if ' ' in arg: + return '"%s"' % arg + return arg + args = [quote(arg) for arg in args] + return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 + +DEFAULT_VERSION = "0.6.28" +DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" +SETUPTOOLS_FAKED_VERSION = "0.6c11" + +SETUPTOOLS_PKG_INFO = """\ +Metadata-Version: 1.0 +Name: setuptools +Version: %s +Summary: xxxx +Home-page: xxx +Author: xxx +Author-email: xxx +License: xxx +Description: xxx +""" % SETUPTOOLS_FAKED_VERSION + + +def _install(tarball, install_args=()): + # extracting the tarball + tmpdir = tempfile.mkdtemp() + log.warn('Extracting in %s', tmpdir) + old_wd = os.getcwd() + try: + os.chdir(tmpdir) + tar = tarfile.open(tarball) + _extractall(tar) + tar.close() + + # going in the directory + subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) + os.chdir(subdir) + log.warn('Now working in %s', subdir) + + # installing + log.warn('Installing Distribute') + if not _python_cmd('setup.py', 'install', *install_args): + log.warn('Something went wrong during the installation.') + log.warn('See the error message above.') + finally: + os.chdir(old_wd) + + +def _build_egg(egg, tarball, to_dir): + # extracting the tarball + tmpdir = tempfile.mkdtemp() + log.warn('Extracting in %s', tmpdir) + old_wd = os.getcwd() + try: + os.chdir(tmpdir) + tar = tarfile.open(tarball) + _extractall(tar) + tar.close() + + # going in the directory + subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) + os.chdir(subdir) + log.warn('Now working in %s', subdir) + + # building an egg + log.warn('Building a Distribute egg in %s', to_dir) + _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) + + finally: + os.chdir(old_wd) + # returning the result + log.warn(egg) + if not os.path.exists(egg): + raise IOError('Could not build the egg.') + + +def _do_download(version, download_base, to_dir, download_delay): + egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg' + % (version, sys.version_info[0], sys.version_info[1])) + if not os.path.exists(egg): + tarball = download_setuptools(version, download_base, + to_dir, download_delay) + _build_egg(egg, tarball, to_dir) + sys.path.insert(0, egg) + import setuptools + setuptools.bootstrap_install_from = egg + + +def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, download_delay=15, no_fake=True): + # making sure we use the absolute path + to_dir = os.path.abspath(to_dir) + was_imported = 'pkg_resources' in sys.modules or \ + 'setuptools' in sys.modules + try: + try: + import pkg_resources + if not hasattr(pkg_resources, '_distribute'): + if not no_fake: + _fake_setuptools() + raise ImportError + except ImportError: + return _do_download(version, download_base, to_dir, download_delay) + try: + pkg_resources.require("distribute>=" + version) + return + except pkg_resources.VersionConflict: + e = sys.exc_info()[1] + if was_imported: + sys.stderr.write( + "The required version of distribute (>=%s) is not available,\n" + "and can't be installed while this script is running. Please\n" + "install a more recent version first, using\n" + "'easy_install -U distribute'." + "\n\n(Currently using %r)\n" % (version, e.args[0])) + sys.exit(2) + else: + del pkg_resources, sys.modules['pkg_resources'] # reload ok + return _do_download(version, download_base, to_dir, + download_delay) + except pkg_resources.DistributionNotFound: + return _do_download(version, download_base, to_dir, + download_delay) + finally: + if not no_fake: + _create_fake_setuptools_pkg_info(to_dir) + + +def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, delay=15): + """Download distribute from a specified location and return its filename + + `version` should be a valid distribute version number that is available + as an egg for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download + attempt. + """ + # making sure we use the absolute path + to_dir = os.path.abspath(to_dir) + try: + from urllib.request import urlopen + except ImportError: + from urllib2 import urlopen + tgz_name = "distribute-%s.tar.gz" % version + url = download_base + tgz_name + saveto = os.path.join(to_dir, tgz_name) + src = dst = None + if not os.path.exists(saveto): # Avoid repeated downloads + try: + log.warn("Downloading %s", url) + src = urlopen(url) + # Read/write all in one block, so we don't create a corrupt file + # if the download is interrupted. + data = src.read() + dst = open(saveto, "wb") + dst.write(data) + finally: + if src: + src.close() + if dst: + dst.close() + return os.path.realpath(saveto) + + +def _no_sandbox(function): + def __no_sandbox(*args, **kw): + try: + from setuptools.sandbox import DirectorySandbox + if not hasattr(DirectorySandbox, '_old'): + def violation(*args): + pass + DirectorySandbox._old = DirectorySandbox._violation + DirectorySandbox._violation = violation + patched = True + else: + patched = False + except ImportError: + patched = False + + try: + return function(*args, **kw) + finally: + if patched: + DirectorySandbox._violation = DirectorySandbox._old + del DirectorySandbox._old + + return __no_sandbox + + +def _patch_file(path, content): + """Will backup the file then patch it""" + existing_content = open(path).read() + if existing_content == content: + # already patched + log.warn('Already patched.') + return False + log.warn('Patching...') + _rename_path(path) + f = open(path, 'w') + try: + f.write(content) + finally: + f.close() + return True + +_patch_file = _no_sandbox(_patch_file) + + +def _same_content(path, content): + return open(path).read() == content + + +def _rename_path(path): + new_name = path + '.OLD.%s' % time.time() + log.warn('Renaming %s into %s', path, new_name) + os.rename(path, new_name) + return new_name + + +def _remove_flat_installation(placeholder): + if not os.path.isdir(placeholder): + log.warn('Unkown installation at %s', placeholder) + return False + found = False + for file in os.listdir(placeholder): + if fnmatch.fnmatch(file, 'setuptools*.egg-info'): + found = True + break + if not found: + log.warn('Could not locate setuptools*.egg-info') + return + + log.warn('Removing elements out of the way...') + pkg_info = os.path.join(placeholder, file) + if os.path.isdir(pkg_info): + patched = _patch_egg_dir(pkg_info) + else: + patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) + + if not patched: + log.warn('%s already patched.', pkg_info) + return False + # now let's move the files out of the way + for element in ('setuptools', 'pkg_resources.py', 'site.py'): + element = os.path.join(placeholder, element) + if os.path.exists(element): + _rename_path(element) + else: + log.warn('Could not find the %s element of the ' + 'Setuptools distribution', element) + return True + +_remove_flat_installation = _no_sandbox(_remove_flat_installation) + + +def _after_install(dist): + log.warn('After install bootstrap.') + placeholder = dist.get_command_obj('install').install_purelib + _create_fake_setuptools_pkg_info(placeholder) + + +def _create_fake_setuptools_pkg_info(placeholder): + if not placeholder or not os.path.exists(placeholder): + log.warn('Could not find the install location') + return + pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) + setuptools_file = 'setuptools-%s-py%s.egg-info' % \ + (SETUPTOOLS_FAKED_VERSION, pyver) + pkg_info = os.path.join(placeholder, setuptools_file) + if os.path.exists(pkg_info): + log.warn('%s already exists', pkg_info) + return + + if not os.access(pkg_info, os.W_OK): + log.warn("Don't have permissions to write %s, skipping", pkg_info) + + log.warn('Creating %s', pkg_info) + f = open(pkg_info, 'w') + try: + f.write(SETUPTOOLS_PKG_INFO) + finally: + f.close() + + pth_file = os.path.join(placeholder, 'setuptools.pth') + log.warn('Creating %s', pth_file) + f = open(pth_file, 'w') + try: + f.write(os.path.join(os.curdir, setuptools_file)) + finally: + f.close() + +_create_fake_setuptools_pkg_info = _no_sandbox( + _create_fake_setuptools_pkg_info +) + + +def _patch_egg_dir(path): + # let's check if it's already patched + pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') + if os.path.exists(pkg_info): + if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): + log.warn('%s already patched.', pkg_info) + return False + _rename_path(path) + os.mkdir(path) + os.mkdir(os.path.join(path, 'EGG-INFO')) + pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') + f = open(pkg_info, 'w') + try: + f.write(SETUPTOOLS_PKG_INFO) + finally: + f.close() + return True + +_patch_egg_dir = _no_sandbox(_patch_egg_dir) + + +def _before_install(): + log.warn('Before install bootstrap.') + _fake_setuptools() + + +def _under_prefix(location): + if 'install' not in sys.argv: + return True + args = sys.argv[sys.argv.index('install') + 1:] + for index, arg in enumerate(args): + for option in ('--root', '--prefix'): + if arg.startswith('%s=' % option): + top_dir = arg.split('root=')[-1] + return location.startswith(top_dir) + elif arg == option: + if len(args) > index: + top_dir = args[index + 1] + return location.startswith(top_dir) + if arg == '--user' and USER_SITE is not None: + return location.startswith(USER_SITE) + return True + + +def _fake_setuptools(): + log.warn('Scanning installed packages') + try: + import pkg_resources + except ImportError: + # we're cool + log.warn('Setuptools or Distribute does not seem to be installed.') + return + ws = pkg_resources.working_set + try: + setuptools_dist = ws.find( + pkg_resources.Requirement.parse('setuptools', replacement=False) + ) + except TypeError: + # old distribute API + setuptools_dist = ws.find( + pkg_resources.Requirement.parse('setuptools') + ) + + if setuptools_dist is None: + log.warn('No setuptools distribution found') + return + # detecting if it was already faked + setuptools_location = setuptools_dist.location + log.warn('Setuptools installation detected at %s', setuptools_location) + + # if --root or --preix was provided, and if + # setuptools is not located in them, we don't patch it + if not _under_prefix(setuptools_location): + log.warn('Not patching, --root or --prefix is installing Distribute' + ' in another location') + return + + # let's see if its an egg + if not setuptools_location.endswith('.egg'): + log.warn('Non-egg installation') + res = _remove_flat_installation(setuptools_location) + if not res: + return + else: + log.warn('Egg installation') + pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') + if (os.path.exists(pkg_info) and + _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): + log.warn('Already patched.') + return + log.warn('Patching...') + # let's create a fake egg replacing setuptools one + res = _patch_egg_dir(setuptools_location) + if not res: + return + log.warn('Patched done.') + _relaunch() + + +def _relaunch(): + log.warn('Relaunching...') + # we have to relaunch the process + # pip marker to avoid a relaunch bug + _cmd = ['-c', 'install', '--single-version-externally-managed'] + if sys.argv[:3] == _cmd: + sys.argv[0] = 'setup.py' + args = [sys.executable] + sys.argv + sys.exit(subprocess.call(args)) + + +def _extractall(self, path=".", members=None): + """Extract all members from the archive to the current working + directory and set owner, modification time and permissions on + directories afterwards. `path' specifies a different directory + to extract to. `members' is optional and must be a subset of the + list returned by getmembers(). + """ + import copy + import operator + from tarfile import ExtractError + directories = [] + + if members is None: + members = self + + for tarinfo in members: + if tarinfo.isdir(): + # Extract directories with a safe mode. + directories.append(tarinfo) + tarinfo = copy.copy(tarinfo) + tarinfo.mode = 448 # decimal for oct 0700 + self.extract(tarinfo, path) + + # Reverse sort directories. + if sys.version_info < (2, 4): + def sorter(dir1, dir2): + return cmp(dir1.name, dir2.name) + directories.sort(sorter) + directories.reverse() + else: + directories.sort(key=operator.attrgetter('name'), reverse=True) + + # Set correct owner, mtime and filemode on directories. + for tarinfo in directories: + dirpath = os.path.join(path, tarinfo.name) + try: + self.chown(tarinfo, dirpath) + self.utime(tarinfo, dirpath) + self.chmod(tarinfo, dirpath) + except ExtractError: + e = sys.exc_info()[1] + if self.errorlevel > 1: + raise + else: + self._dbg(1, "tarfile: %s" % e) + + +def _build_install_args(argv): + install_args = [] + user_install = '--user' in argv + if user_install and sys.version_info < (2, 6): + log.warn("--user requires Python 2.6 or later") + raise SystemExit(1) + if user_install: + install_args.append('--user') + return install_args + + +def main(argv, version=DEFAULT_VERSION): + """Install or upgrade setuptools and EasyInstall""" + tarball = download_setuptools() + _install(tarball, _build_install_args(argv)) + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/setup.py b/setup.py index c152a306b..905d42c9c 100644 --- a/setup.py +++ b/setup.py @@ -43,30 +43,20 @@ import textwrap from src.version import version -plugins = [s for s in os.listdir('plugins') if - os.path.exists(os.path.join('plugins', s, 'plugin.py'))] +try: + from distribute_setup import use_setuptools +except ImportError: + pass +else: + use_setuptools(version='0.6c9') + +from setuptools import setup def normalizeWhitespace(s): return ' '.join(s.split()) -try: - from setuptools import setup -except ImportError, e: - s = normalizeWhitespace("""Supybot requires the distutils package to - install. This package is normally included with Python, but for some - unfathomable reason, many distributions to take it out of standard Python - and put it in another package, usually caled 'python-dev' or python-devel' - or something similar. This is one of the dumbest things a distribution can - do, because it means that developers cannot rely on *STANDARD* Python - modules to be present on systems of that distribution. Complain to your - distribution, and loudly. If you how much of our time we've wasted telling - people to install what should be included by default with Python you'd - understand why we're unhappy about this. Anyway, to reiterate, install the - development package for Python that your distribution supplies.""") - sys.stderr.write(os.linesep*2) - sys.stderr.write(textwrap.fill(s)) - sys.stderr.write(os.linesep*2) - sys.exit(-1) +plugins = [s for s in os.listdir('plugins') if + os.path.exists(os.path.join('plugins', s, 'plugin.py'))] packages = ['supybot', 'supybot.utils', From aa4071fa68b1083e93a501e7334c5f13d13fc480 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sun, 14 Oct 2012 10:41:56 -0400 Subject: [PATCH 12/67] RSS: Remove local/feedparser and add it to install_requires Signed-off-by: James McCoy --- plugins/RSS/local/__init__.py | 1 - plugins/RSS/local/feedparser.py | 2858 ------------------------------- plugins/RSS/plugin.py | 8 +- plugins/RSS/test.py | 1 - setup.py | 3 +- 5 files changed, 2 insertions(+), 2869 deletions(-) delete mode 100644 plugins/RSS/local/__init__.py delete mode 100644 plugins/RSS/local/feedparser.py diff --git a/plugins/RSS/local/__init__.py b/plugins/RSS/local/__init__.py deleted file mode 100644 index e86e97b86..000000000 --- a/plugins/RSS/local/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Stub so local is a module, used for third-party modules diff --git a/plugins/RSS/local/feedparser.py b/plugins/RSS/local/feedparser.py deleted file mode 100644 index bb802df16..000000000 --- a/plugins/RSS/local/feedparser.py +++ /dev/null @@ -1,2858 +0,0 @@ -#!/usr/bin/env python -"""Universal feed parser - -Handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds - -Visit http://feedparser.org/ for the latest version -Visit http://feedparser.org/docs/ for the latest documentation - -Required: Python 2.1 or later -Recommended: Python 2.3 or later -Recommended: CJKCodecs and iconv_codec -""" - -__version__ = "4.1"# + "$Revision: 1.92 $"[11:15] + "-cvs" -__license__ = """Copyright (c) 2002-2006, Mark Pilgrim, All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE.""" -__author__ = "Mark Pilgrim " -__contributors__ = ["Jason Diamond ", - "John Beimler ", - "Fazal Majid ", - "Aaron Swartz ", - "Kevin Marks "] -_debug = 0 - -# HTTP "User-Agent" header to send to servers when downloading feeds. -# If you are embedding feedparser in a larger application, you should -# change this to your application name and URL. -USER_AGENT = "UniversalFeedParser/%s +http://feedparser.org/" % __version__ - -# HTTP "Accept" header to send to servers when downloading feeds. If you don't -# want to send an Accept header, set this to None. -ACCEPT_HEADER = "application/atom+xml,application/rdf+xml,application/rss+xml,application/x-netcdf,application/xml;q=0.9,text/xml;q=0.2,*/*;q=0.1" - -# List of preferred XML parsers, by SAX driver name. These will be tried first, -# but if they're not installed, Python will keep searching through its own list -# of pre-installed parsers until it finds one that supports everything we need. -PREFERRED_XML_PARSERS = ["drv_libxml2"] - -# If you want feedparser to automatically run HTML markup through HTML Tidy, set -# this to 1. Requires mxTidy -# or utidylib . -TIDY_MARKUP = 0 - -# List of Python interfaces for HTML Tidy, in order of preference. Only useful -# if TIDY_MARKUP = 1 -PREFERRED_TIDY_INTERFACES = ["uTidy", "mxTidy"] - -# ---------- required modules (should come with any Python distribution) ---------- -import sgmllib, re, sys, copy, urlparse, time, rfc822, types, cgi, urllib, urllib2 -try: - from cStringIO import StringIO as _StringIO -except: - from StringIO import StringIO as _StringIO - -# ---------- optional modules (feedparser will work without these, but with reduced functionality) ---------- - -# gzip is included with most Python distributions, but may not be available if you compiled your own -try: - import gzip -except: - gzip = None -try: - import zlib -except: - zlib = None - -# If a real XML parser is available, feedparser will attempt to use it. feedparser has -# been tested with the built-in SAX parser, PyXML, and libxml2. On platforms where the -# Python distribution does not come with an XML parser (such as Mac OS X 10.2 and some -# versions of FreeBSD), feedparser will quietly fall back on regex-based parsing. -try: - import xml.sax - xml.sax.make_parser(PREFERRED_XML_PARSERS) # test for valid parsers - from xml.sax.saxutils import escape as _xmlescape - _XML_AVAILABLE = 1 -except: - _XML_AVAILABLE = 0 - def _xmlescape(data): - data = data.replace('&', '&') - data = data.replace('>', '>') - data = data.replace('<', '<') - return data - -# base64 support for Atom feeds that contain embedded binary data -try: - import base64, binascii -except: - base64 = binascii = None - -# cjkcodecs and iconv_codec provide support for more character encodings. -# Both are available from http://cjkpython.i18n.org/ -try: - import cjkcodecs.aliases -except: - pass -try: - import iconv_codec -except: - pass - -# chardet library auto-detects character encodings -# Download from http://chardet.feedparser.org/ -try: - import chardet - if _debug: - import chardet.constants - chardet.constants._debug = 1 -except: - chardet = None - -# ---------- don't touch these ---------- -class ThingsNobodyCaresAboutButMe(Exception): pass -class CharacterEncodingOverride(ThingsNobodyCaresAboutButMe): pass -class CharacterEncodingUnknown(ThingsNobodyCaresAboutButMe): pass -class NonXMLContentType(ThingsNobodyCaresAboutButMe): pass -class UndeclaredNamespace(Exception): pass - -sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*') -sgmllib.special = re.compile('' % (tag, ''.join([' %s="%s"' % t for t in attrs])), escape=0) - - # match namespaces - if tag.find(':') <> -1: - prefix, suffix = tag.split(':', 1) - else: - prefix, suffix = '', tag - prefix = self.namespacemap.get(prefix, prefix) - if prefix: - prefix = prefix + '_' - - # special hack for better tracking of empty textinput/image elements in illformed feeds - if (not prefix) and tag not in ('title', 'link', 'description', 'name'): - self.intextinput = 0 - if (not prefix) and tag not in ('title', 'link', 'description', 'url', 'href', 'width', 'height'): - self.inimage = 0 - - # call special handler (if defined) or default handler - methodname = '_start_' + prefix + suffix - try: - method = getattr(self, methodname) - return method(attrsD) - except AttributeError: - return self.push(prefix + suffix, 1) - - def unknown_endtag(self, tag): - if _debug: sys.stderr.write('end %s\n' % tag) - # match namespaces - if tag.find(':') <> -1: - prefix, suffix = tag.split(':', 1) - else: - prefix, suffix = '', tag - prefix = self.namespacemap.get(prefix, prefix) - if prefix: - prefix = prefix + '_' - - # call special handler (if defined) or default handler - methodname = '_end_' + prefix + suffix - try: - method = getattr(self, methodname) - method() - except AttributeError: - self.pop(prefix + suffix) - - # track inline content - if self.incontent and self.contentparams.has_key('type') and not self.contentparams.get('type', 'xml').endswith('xml'): - # element declared itself as escaped markup, but it isn't really - self.contentparams['type'] = 'application/xhtml+xml' - if self.incontent and self.contentparams.get('type') == 'application/xhtml+xml': - tag = tag.split(':')[-1] - self.handle_data('' % tag, escape=0) - - # track xml:base and xml:lang going out of scope - if self.basestack: - self.basestack.pop() - if self.basestack and self.basestack[-1]: - self.baseuri = self.basestack[-1] - if self.langstack: - self.langstack.pop() - if self.langstack: # and (self.langstack[-1] is not None): - self.lang = self.langstack[-1] - - def handle_charref(self, ref): - # called for each character reference, e.g. for ' ', ref will be '160' - if not self.elementstack: return - ref = ref.lower() - if ref in ('34', '38', '39', '60', '62', 'x22', 'x26', 'x27', 'x3c', 'x3e'): - text = '&#%s;' % ref - else: - if ref[0] == 'x': - c = int(ref[1:], 16) - else: - c = int(ref) - text = unichr(c).encode('utf-8') - self.elementstack[-1][2].append(text) - - def handle_entityref(self, ref): - # called for each entity reference, e.g. for '©', ref will be 'copy' - if not self.elementstack: return - if _debug: sys.stderr.write('entering handle_entityref with %s\n' % ref) - if ref in ('lt', 'gt', 'quot', 'amp', 'apos'): - text = '&%s;' % ref - else: - # entity resolution graciously donated by Aaron Swartz - def name2cp(k): - import htmlentitydefs - if hasattr(htmlentitydefs, 'name2codepoint'): # requires Python 2.3 - return htmlentitydefs.name2codepoint[k] - k = htmlentitydefs.entitydefs[k] - if k.startswith('&#') and k.endswith(';'): - return int(k[2:-1]) # not in latin-1 - return ord(k) - try: name2cp(ref) - except KeyError: text = '&%s;' % ref - else: text = unichr(name2cp(ref)).encode('utf-8') - self.elementstack[-1][2].append(text) - - def handle_data(self, text, escape=1): - # called for each block of plain text, i.e. outside of any tag and - # not containing any character or entity references - if not self.elementstack: return - if escape and self.contentparams.get('type') == 'application/xhtml+xml': - text = _xmlescape(text) - self.elementstack[-1][2].append(text) - - def handle_comment(self, text): - # called for each comment, e.g. - pass - - def handle_pi(self, text): - # called for each processing instruction, e.g. - pass - - def handle_decl(self, text): - pass - - def parse_declaration(self, i): - # override internal declaration handler to handle CDATA blocks - if _debug: sys.stderr.write('entering parse_declaration\n') - if self.rawdata[i:i+9] == '', i) - if k == -1: k = len(self.rawdata) - self.handle_data(_xmlescape(self.rawdata[i+9:k]), 0) - return k+3 - else: - k = self.rawdata.find('>', i) - return k+1 - - def mapContentType(self, contentType): - contentType = contentType.lower() - if contentType == 'text': - contentType = 'text/plain' - elif contentType == 'html': - contentType = 'text/html' - elif contentType == 'xhtml': - contentType = 'application/xhtml+xml' - return contentType - - def trackNamespace(self, prefix, uri): - loweruri = uri.lower() - if (prefix, loweruri) == (None, 'http://my.netscape.com/rdf/simple/0.9/') and not self.version: - self.version = 'rss090' - if loweruri == 'http://purl.org/rss/1.0/' and not self.version: - self.version = 'rss10' - if loweruri == 'http://www.w3.org/2005/atom' and not self.version: - self.version = 'atom10' - if loweruri.find('backend.userland.com/rss') <> -1: - # match any backend.userland.com namespace - uri = 'http://backend.userland.com/rss' - loweruri = uri - if self._matchnamespaces.has_key(loweruri): - self.namespacemap[prefix] = self._matchnamespaces[loweruri] - self.namespacesInUse[self._matchnamespaces[loweruri]] = uri - else: - self.namespacesInUse[prefix or ''] = uri - - def resolveURI(self, uri): - return _urljoin(self.baseuri or '', uri) - - def decodeEntities(self, element, data): - return data - - def push(self, element, expectingText): - self.elementstack.append([element, expectingText, []]) - - def pop(self, element, stripWhitespace=1): - if not self.elementstack: return - if self.elementstack[-1][0] != element: return - - element, expectingText, pieces = self.elementstack.pop() - output = ''.join(pieces) - if stripWhitespace: - output = output.strip() - if not expectingText: return output - - # decode base64 content - if base64 and self.contentparams.get('base64', 0): - try: - output = base64.decodestring(output) - except binascii.Error: - pass - except binascii.Incomplete: - pass - - # resolve relative URIs - if (element in self.can_be_relative_uri) and output: - output = self.resolveURI(output) - - # decode entities within embedded markup - if not self.contentparams.get('base64', 0): - output = self.decodeEntities(element, output) - - # remove temporary cruft from contentparams - try: - del self.contentparams['mode'] - except KeyError: - pass - try: - del self.contentparams['base64'] - except KeyError: - pass - - # resolve relative URIs within embedded markup - if self.mapContentType(self.contentparams.get('type', 'text/html')) in self.html_types: - if element in self.can_contain_relative_uris: - output = _resolveRelativeURIs(output, self.baseuri, self.encoding) - - # sanitize embedded markup - if self.mapContentType(self.contentparams.get('type', 'text/html')) in self.html_types: - if element in self.can_contain_dangerous_markup: - output = _sanitizeHTML(output, self.encoding) - - if self.encoding and type(output) != type(u''): - try: - output = unicode(output, self.encoding) - except: - pass - - # categories/tags/keywords/whatever are handled in _end_category - if element == 'category': - return output - - # store output in appropriate place(s) - if self.inentry and not self.insource: - if element == 'content': - self.entries[-1].setdefault(element, []) - contentparams = copy.deepcopy(self.contentparams) - contentparams['value'] = output - self.entries[-1][element].append(contentparams) - elif element == 'link': - self.entries[-1][element] = output - if output: - self.entries[-1]['links'][-1]['href'] = output - else: - if element == 'description': - element = 'summary' - self.entries[-1][element] = output - if self.incontent: - contentparams = copy.deepcopy(self.contentparams) - contentparams['value'] = output - self.entries[-1][element + '_detail'] = contentparams - elif (self.infeed or self.insource) and (not self.intextinput) and (not self.inimage): - context = self._getContext() - if element == 'description': - element = 'subtitle' - context[element] = output - if element == 'link': - context['links'][-1]['href'] = output - elif self.incontent: - contentparams = copy.deepcopy(self.contentparams) - contentparams['value'] = output - context[element + '_detail'] = contentparams - return output - - def pushContent(self, tag, attrsD, defaultContentType, expectingText): - self.incontent += 1 - self.contentparams = FeedParserDict({ - 'type': self.mapContentType(attrsD.get('type', defaultContentType)), - 'language': self.lang, - 'base': self.baseuri}) - self.contentparams['base64'] = self._isBase64(attrsD, self.contentparams) - self.push(tag, expectingText) - - def popContent(self, tag): - value = self.pop(tag) - self.incontent -= 1 - self.contentparams.clear() - return value - - def _mapToStandardPrefix(self, name): - colonpos = name.find(':') - if colonpos <> -1: - prefix = name[:colonpos] - suffix = name[colonpos+1:] - prefix = self.namespacemap.get(prefix, prefix) - name = prefix + ':' + suffix - return name - - def _getAttribute(self, attrsD, name): - return attrsD.get(self._mapToStandardPrefix(name)) - - def _isBase64(self, attrsD, contentparams): - if attrsD.get('mode', '') == 'base64': - return 1 - if self.contentparams['type'].startswith('text/'): - return 0 - if self.contentparams['type'].endswith('+xml'): - return 0 - if self.contentparams['type'].endswith('/xml'): - return 0 - return 1 - - def _itsAnHrefDamnIt(self, attrsD): - href = attrsD.get('url', attrsD.get('uri', attrsD.get('href', None))) - if href: - try: - del attrsD['url'] - except KeyError: - pass - try: - del attrsD['uri'] - except KeyError: - pass - attrsD['href'] = href - return attrsD - - def _save(self, key, value): - context = self._getContext() - context.setdefault(key, value) - - def _start_rss(self, attrsD): - versionmap = {'0.91': 'rss091u', - '0.92': 'rss092', - '0.93': 'rss093', - '0.94': 'rss094'} - if not self.version: - attr_version = attrsD.get('version', '') - version = versionmap.get(attr_version) - if version: - self.version = version - elif attr_version.startswith('2.'): - self.version = 'rss20' - else: - self.version = 'rss' - - def _start_dlhottitles(self, attrsD): - self.version = 'hotrss' - - def _start_channel(self, attrsD): - self.infeed = 1 - self._cdf_common(attrsD) - _start_feedinfo = _start_channel - - def _cdf_common(self, attrsD): - if attrsD.has_key('lastmod'): - self._start_modified({}) - self.elementstack[-1][-1] = attrsD['lastmod'] - self._end_modified() - if attrsD.has_key('href'): - self._start_link({}) - self.elementstack[-1][-1] = attrsD['href'] - self._end_link() - - def _start_feed(self, attrsD): - self.infeed = 1 - versionmap = {'0.1': 'atom01', - '0.2': 'atom02', - '0.3': 'atom03'} - if not self.version: - attr_version = attrsD.get('version') - version = versionmap.get(attr_version) - if version: - self.version = version - else: - self.version = 'atom' - - def _end_channel(self): - self.infeed = 0 - _end_feed = _end_channel - - def _start_image(self, attrsD): - self.inimage = 1 - self.push('image', 0) - context = self._getContext() - context.setdefault('image', FeedParserDict()) - - def _end_image(self): - self.pop('image') - self.inimage = 0 - - def _start_textinput(self, attrsD): - self.intextinput = 1 - self.push('textinput', 0) - context = self._getContext() - context.setdefault('textinput', FeedParserDict()) - _start_textInput = _start_textinput - - def _end_textinput(self): - self.pop('textinput') - self.intextinput = 0 - _end_textInput = _end_textinput - - def _start_author(self, attrsD): - self.inauthor = 1 - self.push('author', 1) - _start_managingeditor = _start_author - _start_dc_author = _start_author - _start_dc_creator = _start_author - _start_itunes_author = _start_author - - def _end_author(self): - self.pop('author') - self.inauthor = 0 - self._sync_author_detail() - _end_managingeditor = _end_author - _end_dc_author = _end_author - _end_dc_creator = _end_author - _end_itunes_author = _end_author - - def _start_itunes_owner(self, attrsD): - self.inpublisher = 1 - self.push('publisher', 0) - - def _end_itunes_owner(self): - self.pop('publisher') - self.inpublisher = 0 - self._sync_author_detail('publisher') - - def _start_contributor(self, attrsD): - self.incontributor = 1 - context = self._getContext() - context.setdefault('contributors', []) - context['contributors'].append(FeedParserDict()) - self.push('contributor', 0) - - def _end_contributor(self): - self.pop('contributor') - self.incontributor = 0 - - def _start_dc_contributor(self, attrsD): - self.incontributor = 1 - context = self._getContext() - context.setdefault('contributors', []) - context['contributors'].append(FeedParserDict()) - self.push('name', 0) - - def _end_dc_contributor(self): - self._end_name() - self.incontributor = 0 - - def _start_name(self, attrsD): - self.push('name', 0) - _start_itunes_name = _start_name - - def _end_name(self): - value = self.pop('name') - if self.inpublisher: - self._save_author('name', value, 'publisher') - elif self.inauthor: - self._save_author('name', value) - elif self.incontributor: - self._save_contributor('name', value) - elif self.intextinput: - context = self._getContext() - context['textinput']['name'] = value - _end_itunes_name = _end_name - - def _start_width(self, attrsD): - self.push('width', 0) - - def _end_width(self): - value = self.pop('width') - try: - value = int(value) - except: - value = 0 - if self.inimage: - context = self._getContext() - context['image']['width'] = value - - def _start_height(self, attrsD): - self.push('height', 0) - - def _end_height(self): - value = self.pop('height') - try: - value = int(value) - except: - value = 0 - if self.inimage: - context = self._getContext() - context['image']['height'] = value - - def _start_url(self, attrsD): - self.push('href', 1) - _start_homepage = _start_url - _start_uri = _start_url - - def _end_url(self): - value = self.pop('href') - if self.inauthor: - self._save_author('href', value) - elif self.incontributor: - self._save_contributor('href', value) - elif self.inimage: - context = self._getContext() - context['image']['href'] = value - elif self.intextinput: - context = self._getContext() - context['textinput']['link'] = value - _end_homepage = _end_url - _end_uri = _end_url - - def _start_email(self, attrsD): - self.push('email', 0) - _start_itunes_email = _start_email - - def _end_email(self): - value = self.pop('email') - if self.inpublisher: - self._save_author('email', value, 'publisher') - elif self.inauthor: - self._save_author('email', value) - elif self.incontributor: - self._save_contributor('email', value) - _end_itunes_email = _end_email - - def _getContext(self): - if self.insource: - context = self.sourcedata - elif self.inentry: - context = self.entries[-1] - else: - context = self.feeddata - return context - - def _save_author(self, key, value, prefix='author'): - context = self._getContext() - context.setdefault(prefix + '_detail', FeedParserDict()) - context[prefix + '_detail'][key] = value - self._sync_author_detail() - - def _save_contributor(self, key, value): - context = self._getContext() - context.setdefault('contributors', [FeedParserDict()]) - context['contributors'][-1][key] = value - - def _sync_author_detail(self, key='author'): - context = self._getContext() - detail = context.get('%s_detail' % key) - if detail: - name = detail.get('name') - email = detail.get('email') - if name and email: - context[key] = '%s (%s)' % (name, email) - elif name: - context[key] = name - elif email: - context[key] = email - else: - author = context.get(key) - if not author: return - emailmatch = re.search(r'''(([a-zA-Z0-9\_\-\.\+]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?))''', author) - if not emailmatch: return - email = emailmatch.group(0) - # probably a better way to do the following, but it passes all the tests - author = author.replace(email, '') - author = author.replace('()', '') - author = author.strip() - if author and (author[0] == '('): - author = author[1:] - if author and (author[-1] == ')'): - author = author[:-1] - author = author.strip() - context.setdefault('%s_detail' % key, FeedParserDict()) - context['%s_detail' % key]['name'] = author - context['%s_detail' % key]['email'] = email - - def _start_subtitle(self, attrsD): - self.pushContent('subtitle', attrsD, 'text/plain', 1) - _start_tagline = _start_subtitle - _start_itunes_subtitle = _start_subtitle - - def _end_subtitle(self): - self.popContent('subtitle') - _end_tagline = _end_subtitle - _end_itunes_subtitle = _end_subtitle - - def _start_rights(self, attrsD): - self.pushContent('rights', attrsD, 'text/plain', 1) - _start_dc_rights = _start_rights - _start_copyright = _start_rights - - def _end_rights(self): - self.popContent('rights') - _end_dc_rights = _end_rights - _end_copyright = _end_rights - - def _start_item(self, attrsD): - self.entries.append(FeedParserDict()) - self.push('item', 0) - self.inentry = 1 - self.guidislink = 0 - id = self._getAttribute(attrsD, 'rdf:about') - if id: - context = self._getContext() - context['id'] = id - self._cdf_common(attrsD) - _start_entry = _start_item - _start_product = _start_item - - def _end_item(self): - self.pop('item') - self.inentry = 0 - _end_entry = _end_item - - def _start_dc_language(self, attrsD): - self.push('language', 1) - _start_language = _start_dc_language - - def _end_dc_language(self): - self.lang = self.pop('language') - _end_language = _end_dc_language - - def _start_dc_publisher(self, attrsD): - self.push('publisher', 1) - _start_webmaster = _start_dc_publisher - - def _end_dc_publisher(self): - self.pop('publisher') - self._sync_author_detail('publisher') - _end_webmaster = _end_dc_publisher - - def _start_published(self, attrsD): - self.push('published', 1) - _start_dcterms_issued = _start_published - _start_issued = _start_published - - def _end_published(self): - value = self.pop('published') - self._save('published_parsed', _parse_date(value)) - _end_dcterms_issued = _end_published - _end_issued = _end_published - - def _start_updated(self, attrsD): - self.push('updated', 1) - _start_modified = _start_updated - _start_dcterms_modified = _start_updated - _start_pubdate = _start_updated - _start_dc_date = _start_updated - - def _end_updated(self): - value = self.pop('updated') - parsed_value = _parse_date(value) - self._save('updated_parsed', parsed_value) - _end_modified = _end_updated - _end_dcterms_modified = _end_updated - _end_pubdate = _end_updated - _end_dc_date = _end_updated - - def _start_created(self, attrsD): - self.push('created', 1) - _start_dcterms_created = _start_created - - def _end_created(self): - value = self.pop('created') - self._save('created_parsed', _parse_date(value)) - _end_dcterms_created = _end_created - - def _start_expirationdate(self, attrsD): - self.push('expired', 1) - - def _end_expirationdate(self): - self._save('expired_parsed', _parse_date(self.pop('expired'))) - - def _start_cc_license(self, attrsD): - self.push('license', 1) - value = self._getAttribute(attrsD, 'rdf:resource') - if value: - self.elementstack[-1][2].append(value) - self.pop('license') - - def _start_creativecommons_license(self, attrsD): - self.push('license', 1) - - def _end_creativecommons_license(self): - self.pop('license') - - def _addTag(self, term, scheme, label): - context = self._getContext() - tags = context.setdefault('tags', []) - if (not term) and (not scheme) and (not label): return - value = FeedParserDict({'term': term, 'scheme': scheme, 'label': label}) - if value not in tags: - tags.append(FeedParserDict({'term': term, 'scheme': scheme, 'label': label})) - - def _start_category(self, attrsD): - if _debug: sys.stderr.write('entering _start_category with %s\n' % repr(attrsD)) - term = attrsD.get('term') - scheme = attrsD.get('scheme', attrsD.get('domain')) - label = attrsD.get('label') - self._addTag(term, scheme, label) - self.push('category', 1) - _start_dc_subject = _start_category - _start_keywords = _start_category - - def _end_itunes_keywords(self): - for term in self.pop('itunes_keywords').split(): - self._addTag(term, 'http://www.itunes.com/', None) - - def _start_itunes_category(self, attrsD): - self._addTag(attrsD.get('text'), 'http://www.itunes.com/', None) - self.push('category', 1) - - def _end_category(self): - value = self.pop('category') - if not value: return - context = self._getContext() - tags = context['tags'] - if value and len(tags) and not tags[-1]['term']: - tags[-1]['term'] = value - else: - self._addTag(value, None, None) - _end_dc_subject = _end_category - _end_keywords = _end_category - _end_itunes_category = _end_category - - def _start_cloud(self, attrsD): - self._getContext()['cloud'] = FeedParserDict(attrsD) - - def _start_link(self, attrsD): - attrsD.setdefault('rel', 'alternate') - attrsD.setdefault('type', 'text/html') - attrsD = self._itsAnHrefDamnIt(attrsD) - if attrsD.has_key('href'): - attrsD['href'] = self.resolveURI(attrsD['href']) - expectingText = self.infeed or self.inentry or self.insource - context = self._getContext() - context.setdefault('links', []) - context['links'].append(FeedParserDict(attrsD)) - if attrsD['rel'] == 'enclosure': - self._start_enclosure(attrsD) - if attrsD.has_key('href'): - expectingText = 0 - if (attrsD.get('rel') == 'alternate') and (self.mapContentType(attrsD.get('type')) in self.html_types): - context['link'] = attrsD['href'] - else: - self.push('link', expectingText) - _start_producturl = _start_link - - def _end_link(self): - value = self.pop('link') - context = self._getContext() - if self.intextinput: - context['textinput']['link'] = value - if self.inimage: - context['image']['link'] = value - _end_producturl = _end_link - - def _start_guid(self, attrsD): - self.guidislink = (attrsD.get('ispermalink', 'true') == 'true') - self.push('id', 1) - - def _end_guid(self): - value = self.pop('id') - self._save('guidislink', self.guidislink and not self._getContext().has_key('link')) - if self.guidislink: - # guid acts as link, but only if 'ispermalink' is not present or is 'true', - # and only if the item doesn't already have a link element - self._save('link', value) - - def _start_title(self, attrsD): - self.pushContent('title', attrsD, 'text/plain', self.infeed or self.inentry or self.insource) - _start_dc_title = _start_title - _start_media_title = _start_title - - def _end_title(self): - value = self.popContent('title') - context = self._getContext() - if self.intextinput: - context['textinput']['title'] = value - elif self.inimage: - context['image']['title'] = value - _end_dc_title = _end_title - _end_media_title = _end_title - - def _start_description(self, attrsD): - context = self._getContext() - if context.has_key('summary'): - self._summaryKey = 'content' - self._start_content(attrsD) - else: - self.pushContent('description', attrsD, 'text/html', self.infeed or self.inentry or self.insource) - - def _start_abstract(self, attrsD): - self.pushContent('description', attrsD, 'text/plain', self.infeed or self.inentry or self.insource) - - def _end_description(self): - if self._summaryKey == 'content': - self._end_content() - else: - value = self.popContent('description') - context = self._getContext() - if self.intextinput: - context['textinput']['description'] = value - elif self.inimage: - context['image']['description'] = value - self._summaryKey = None - _end_abstract = _end_description - - def _start_info(self, attrsD): - self.pushContent('info', attrsD, 'text/plain', 1) - _start_feedburner_browserfriendly = _start_info - - def _end_info(self): - self.popContent('info') - _end_feedburner_browserfriendly = _end_info - - def _start_generator(self, attrsD): - if attrsD: - attrsD = self._itsAnHrefDamnIt(attrsD) - if attrsD.has_key('href'): - attrsD['href'] = self.resolveURI(attrsD['href']) - self._getContext()['generator_detail'] = FeedParserDict(attrsD) - self.push('generator', 1) - - def _end_generator(self): - value = self.pop('generator') - context = self._getContext() - if context.has_key('generator_detail'): - context['generator_detail']['name'] = value - - def _start_admin_generatoragent(self, attrsD): - self.push('generator', 1) - value = self._getAttribute(attrsD, 'rdf:resource') - if value: - self.elementstack[-1][2].append(value) - self.pop('generator') - self._getContext()['generator_detail'] = FeedParserDict({'href': value}) - - def _start_admin_errorreportsto(self, attrsD): - self.push('errorreportsto', 1) - value = self._getAttribute(attrsD, 'rdf:resource') - if value: - self.elementstack[-1][2].append(value) - self.pop('errorreportsto') - - def _start_summary(self, attrsD): - context = self._getContext() - if context.has_key('summary'): - self._summaryKey = 'content' - self._start_content(attrsD) - else: - self._summaryKey = 'summary' - self.pushContent(self._summaryKey, attrsD, 'text/plain', 1) - _start_itunes_summary = _start_summary - - def _end_summary(self): - if self._summaryKey == 'content': - self._end_content() - else: - self.popContent(self._summaryKey or 'summary') - self._summaryKey = None - _end_itunes_summary = _end_summary - - def _start_enclosure(self, attrsD): - attrsD = self._itsAnHrefDamnIt(attrsD) - self._getContext().setdefault('enclosures', []).append(FeedParserDict(attrsD)) - href = attrsD.get('href') - if href: - context = self._getContext() - if not context.get('id'): - context['id'] = href - - def _start_source(self, attrsD): - self.insource = 1 - - def _end_source(self): - self.insource = 0 - self._getContext()['source'] = copy.deepcopy(self.sourcedata) - self.sourcedata.clear() - - def _start_content(self, attrsD): - self.pushContent('content', attrsD, 'text/plain', 1) - src = attrsD.get('src') - if src: - self.contentparams['src'] = src - self.push('content', 1) - - def _start_prodlink(self, attrsD): - self.pushContent('content', attrsD, 'text/html', 1) - - def _start_body(self, attrsD): - self.pushContent('content', attrsD, 'application/xhtml+xml', 1) - _start_xhtml_body = _start_body - - def _start_content_encoded(self, attrsD): - self.pushContent('content', attrsD, 'text/html', 1) - _start_fullitem = _start_content_encoded - - def _end_content(self): - copyToDescription = self.mapContentType(self.contentparams.get('type')) in (['text/plain'] + self.html_types) - value = self.popContent('content') - if copyToDescription: - self._save('description', value) - _end_body = _end_content - _end_xhtml_body = _end_content - _end_content_encoded = _end_content - _end_fullitem = _end_content - _end_prodlink = _end_content - - def _start_itunes_image(self, attrsD): - self.push('itunes_image', 0) - self._getContext()['image'] = FeedParserDict({'href': attrsD.get('href')}) - _start_itunes_link = _start_itunes_image - - def _end_itunes_block(self): - value = self.pop('itunes_block', 0) - self._getContext()['itunes_block'] = (value == 'yes') and 1 or 0 - - def _end_itunes_explicit(self): - value = self.pop('itunes_explicit', 0) - self._getContext()['itunes_explicit'] = (value == 'yes') and 1 or 0 - -if _XML_AVAILABLE: - class _StrictFeedParser(_FeedParserMixin, xml.sax.handler.ContentHandler): - def __init__(self, baseuri, baselang, encoding): - if _debug: sys.stderr.write('trying StrictFeedParser\n') - xml.sax.handler.ContentHandler.__init__(self) - _FeedParserMixin.__init__(self, baseuri, baselang, encoding) - self.bozo = 0 - self.exc = None - - def startPrefixMapping(self, prefix, uri): - self.trackNamespace(prefix, uri) - - def startElementNS(self, name, qname, attrs): - namespace, localname = name - lowernamespace = str(namespace or '').lower() - if lowernamespace.find('backend.userland.com/rss') <> -1: - # match any backend.userland.com namespace - namespace = 'http://backend.userland.com/rss' - lowernamespace = namespace - if qname and qname.find(':') > 0: - givenprefix = qname.split(':')[0] - else: - givenprefix = None - prefix = self._matchnamespaces.get(lowernamespace, givenprefix) - if givenprefix and (prefix == None or (prefix == '' and lowernamespace == '')) and not self.namespacesInUse.has_key(givenprefix): - raise UndeclaredNamespace, "'%s' is not associated with a namespace" % givenprefix - if prefix: - localname = prefix + ':' + localname - localname = str(localname).lower() - if _debug: sys.stderr.write('startElementNS: qname = %s, namespace = %s, givenprefix = %s, prefix = %s, attrs = %s, localname = %s\n' % (qname, namespace, givenprefix, prefix, attrs.items(), localname)) - - # qname implementation is horribly broken in Python 2.1 (it - # doesn't report any), and slightly broken in Python 2.2 (it - # doesn't report the xml: namespace). So we match up namespaces - # with a known list first, and then possibly override them with - # the qnames the SAX parser gives us (if indeed it gives us any - # at all). Thanks to MatejC for helping me test this and - # tirelessly telling me that it didn't work yet. - attrsD = {} - for (namespace, attrlocalname), attrvalue in attrs._attrs.items(): - lowernamespace = (namespace or '').lower() - prefix = self._matchnamespaces.get(lowernamespace, '') - if prefix: - attrlocalname = prefix + ':' + attrlocalname - attrsD[str(attrlocalname).lower()] = attrvalue - for qname in attrs.getQNames(): - attrsD[str(qname).lower()] = attrs.getValueByQName(qname) - self.unknown_starttag(localname, attrsD.items()) - - def characters(self, text): - self.handle_data(text) - - def endElementNS(self, name, qname): - namespace, localname = name - lowernamespace = str(namespace or '').lower() - if qname and qname.find(':') > 0: - givenprefix = qname.split(':')[0] - else: - givenprefix = '' - prefix = self._matchnamespaces.get(lowernamespace, givenprefix) - if prefix: - localname = prefix + ':' + localname - localname = str(localname).lower() - self.unknown_endtag(localname) - - def error(self, exc): - self.bozo = 1 - self.exc = exc - - def fatalError(self, exc): - self.error(exc) - raise exc - -class _BaseHTMLProcessor(sgmllib.SGMLParser): - elements_no_end_tag = ['area', 'base', 'basefont', 'br', 'col', 'frame', 'hr', - 'img', 'input', 'isindex', 'link', 'meta', 'param'] - - def __init__(self, encoding): - self.encoding = encoding - if _debug: sys.stderr.write('entering BaseHTMLProcessor, encoding=%s\n' % self.encoding) - sgmllib.SGMLParser.__init__(self) - - def reset(self): - self.pieces = [] - sgmllib.SGMLParser.reset(self) - - def _shorttag_replace(self, match): - tag = match.group(1) - if tag in self.elements_no_end_tag: - return '<' + tag + ' />' - else: - return '<' + tag + '>' - - def feed(self, data): - data = re.compile(r'', self._shorttag_replace, data) # bug [ 1399464 ] Bad regexp for _shorttag_replace - data = re.sub(r'<([^<\s]+?)\s*/>', self._shorttag_replace, data) - data = data.replace(''', "'") - data = data.replace('"', '"') - if self.encoding and type(data) == type(u''): - data = data.encode(self.encoding) - sgmllib.SGMLParser.feed(self, data) - - def normalize_attrs(self, attrs): - # utility method to be called by descendants - attrs = [(k.lower(), v) for k, v in attrs] - attrs = [(k, k in ('rel', 'type') and v.lower() or v) for k, v in attrs] - return attrs - - def unknown_starttag(self, tag, attrs): - # called for each start tag - # attrs is a list of (attr, value) tuples - # e.g. for

, tag='pre', attrs=[('class', 'screen')]
-        if _debug: sys.stderr.write('_BaseHTMLProcessor, unknown_starttag, tag=%s\n' % tag)
-        uattrs = []
-        # thanks to Kevin Marks for this breathtaking hack to deal with (valid) high-bit attribute values in UTF-8 feeds
-        for key, value in attrs:
-            if type(value) != type(u''):
-                value = unicode(value, self.encoding)
-            uattrs.append((unicode(key, self.encoding), value))
-        strattrs = u''.join([u' %s="%s"' % (key, value) for key, value in uattrs]).encode(self.encoding)
-        if tag in self.elements_no_end_tag:
-            self.pieces.append('<%(tag)s%(strattrs)s />' % locals())
-        else:
-            self.pieces.append('<%(tag)s%(strattrs)s>' % locals())
-
-    def unknown_endtag(self, tag):
-        # called for each end tag, e.g. for 
, tag will be 'pre' - # Reconstruct the original end tag. - if tag not in self.elements_no_end_tag: - self.pieces.append("" % locals()) - - def handle_charref(self, ref): - # called for each character reference, e.g. for ' ', ref will be '160' - # Reconstruct the original character reference. - self.pieces.append('&#%(ref)s;' % locals()) - - def handle_entityref(self, ref): - # called for each entity reference, e.g. for '©', ref will be 'copy' - # Reconstruct the original entity reference. - self.pieces.append('&%(ref)s;' % locals()) - - def handle_data(self, text): - # called for each block of plain text, i.e. outside of any tag and - # not containing any character or entity references - # Store the original text verbatim. - if _debug: sys.stderr.write('_BaseHTMLProcessor, handle_text, text=%s\n' % text) - self.pieces.append(text) - - def handle_comment(self, text): - # called for each HTML comment, e.g. - # Reconstruct the original comment. - self.pieces.append('' % locals()) - - def handle_pi(self, text): - # called for each processing instruction, e.g. - # Reconstruct original processing instruction. - self.pieces.append('' % locals()) - - def handle_decl(self, text): - # called for the DOCTYPE, if present, e.g. - # - # Reconstruct original DOCTYPE - self.pieces.append('' % locals()) - - _new_declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9:]*\s*').match - def _scan_name(self, i, declstartpos): - rawdata = self.rawdata - n = len(rawdata) - if i == n: - return None, -1 - m = self._new_declname_match(rawdata, i) - if m: - s = m.group() - name = s.strip() - if (i + len(s)) == n: - return None, -1 # end of buffer - return name.lower(), m.end() - else: - self.handle_data(rawdata) -# self.updatepos(declstartpos, i) - return None, -1 - - def output(self): - '''Return processed HTML as a single string''' - return ''.join([str(p) for p in self.pieces]) - -class _LooseFeedParser(_FeedParserMixin, _BaseHTMLProcessor): - def __init__(self, baseuri, baselang, encoding): - sgmllib.SGMLParser.__init__(self) - _FeedParserMixin.__init__(self, baseuri, baselang, encoding) - - def decodeEntities(self, element, data): - data = data.replace('<', '<') - data = data.replace('<', '<') - data = data.replace('>', '>') - data = data.replace('>', '>') - data = data.replace('&', '&') - data = data.replace('&', '&') - data = data.replace('"', '"') - data = data.replace('"', '"') - data = data.replace(''', ''') - data = data.replace(''', ''') - if self.contentparams.has_key('type') and not self.contentparams.get('type', 'xml').endswith('xml'): - data = data.replace('<', '<') - data = data.replace('>', '>') - data = data.replace('&', '&') - data = data.replace('"', '"') - data = data.replace(''', "'") - return data - -class _RelativeURIResolver(_BaseHTMLProcessor): - relative_uris = [('a', 'href'), - ('applet', 'codebase'), - ('area', 'href'), - ('blockquote', 'cite'), - ('body', 'background'), - ('del', 'cite'), - ('form', 'action'), - ('frame', 'longdesc'), - ('frame', 'src'), - ('iframe', 'longdesc'), - ('iframe', 'src'), - ('head', 'profile'), - ('img', 'longdesc'), - ('img', 'src'), - ('img', 'usemap'), - ('input', 'src'), - ('input', 'usemap'), - ('ins', 'cite'), - ('link', 'href'), - ('object', 'classid'), - ('object', 'codebase'), - ('object', 'data'), - ('object', 'usemap'), - ('q', 'cite'), - ('script', 'src')] - - def __init__(self, baseuri, encoding): - _BaseHTMLProcessor.__init__(self, encoding) - self.baseuri = baseuri - - def resolveURI(self, uri): - return _urljoin(self.baseuri, uri) - - def unknown_starttag(self, tag, attrs): - attrs = self.normalize_attrs(attrs) - attrs = [(key, ((tag, key) in self.relative_uris) and self.resolveURI(value) or value) for key, value in attrs] - _BaseHTMLProcessor.unknown_starttag(self, tag, attrs) - -def _resolveRelativeURIs(htmlSource, baseURI, encoding): - if _debug: sys.stderr.write('entering _resolveRelativeURIs\n') - p = _RelativeURIResolver(baseURI, encoding) - p.feed(htmlSource) - return p.output() - -class _HTMLSanitizer(_BaseHTMLProcessor): - acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'b', 'big', - 'blockquote', 'br', 'button', 'caption', 'center', 'cite', 'code', 'col', - 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt', 'em', 'fieldset', - 'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', - 'ins', 'kbd', 'label', 'legend', 'li', 'map', 'menu', 'ol', 'optgroup', - 'option', 'p', 'pre', 'q', 's', 'samp', 'select', 'small', 'span', 'strike', - 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th', - 'thead', 'tr', 'tt', 'u', 'ul', 'var'] - - acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey', - 'action', 'align', 'alt', 'axis', 'border', 'cellpadding', 'cellspacing', - 'char', 'charoff', 'charset', 'checked', 'cite', 'class', 'clear', 'cols', - 'colspan', 'color', 'compact', 'coords', 'datetime', 'dir', 'disabled', - 'enctype', 'for', 'frame', 'headers', 'height', 'href', 'hreflang', 'hspace', - 'id', 'ismap', 'label', 'lang', 'longdesc', 'maxlength', 'media', 'method', - 'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'prompt', 'readonly', - 'rel', 'rev', 'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size', - 'span', 'src', 'start', 'summary', 'tabindex', 'target', 'title', 'type', - 'usemap', 'valign', 'value', 'vspace', 'width'] - - unacceptable_elements_with_end_tag = ['script', 'applet'] - - def reset(self): - _BaseHTMLProcessor.reset(self) - self.unacceptablestack = 0 - - def unknown_starttag(self, tag, attrs): - if not tag in self.acceptable_elements: - if tag in self.unacceptable_elements_with_end_tag: - self.unacceptablestack += 1 - return - attrs = self.normalize_attrs(attrs) - attrs = [(key, value) for key, value in attrs if key in self.acceptable_attributes] - _BaseHTMLProcessor.unknown_starttag(self, tag, attrs) - - def unknown_endtag(self, tag): - if not tag in self.acceptable_elements: - if tag in self.unacceptable_elements_with_end_tag: - self.unacceptablestack -= 1 - return - _BaseHTMLProcessor.unknown_endtag(self, tag) - - def handle_pi(self, text): - pass - - def handle_decl(self, text): - pass - - def handle_data(self, text): - if not self.unacceptablestack: - _BaseHTMLProcessor.handle_data(self, text) - -def _sanitizeHTML(htmlSource, encoding): - p = _HTMLSanitizer(encoding) - p.feed(htmlSource) - data = p.output() - if TIDY_MARKUP: - # loop through list of preferred Tidy interfaces looking for one that's installed, - # then set up a common _tidy function to wrap the interface-specific API. - _tidy = None - for tidy_interface in PREFERRED_TIDY_INTERFACES: - try: - if tidy_interface == "uTidy": - from tidy import parseString as _utidy - def _tidy(data, **kwargs): - return str(_utidy(data, **kwargs)) - break - elif tidy_interface == "mxTidy": - from mx.Tidy import Tidy as _mxtidy - def _tidy(data, **kwargs): - nerrors, nwarnings, data, errordata = _mxtidy.tidy(data, **kwargs) - return data - break - except: - pass - if _tidy: - utf8 = type(data) == type(u'') - if utf8: - data = data.encode('utf-8') - data = _tidy(data, output_xhtml=1, numeric_entities=1, wrap=0, char_encoding="utf8") - if utf8: - data = unicode(data, 'utf-8') - if data.count(''): - data = data.split('>', 1)[1] - if data.count('= '2.3.3' - assert base64 != None - user, passw = base64.decodestring(req.headers['Authorization'].split(' ')[1]).split(':') - realm = re.findall('realm="([^"]*)"', headers['WWW-Authenticate'])[0] - self.add_password(realm, host, user, passw) - retry = self.http_error_auth_reqed('www-authenticate', host, req, headers) - self.reset_retry_count() - return retry - except: - return self.http_error_default(req, fp, code, msg, headers) - -def _open_resource(url_file_stream_or_string, etag, modified, agent, referrer, handlers): - """URL, filename, or string --> stream - - This function lets you define parsers that take any input source - (URL, pathname to local or network file, or actual data as a string) - and deal with it in a uniform manner. Returned object is guaranteed - to have all the basic stdio read methods (read, readline, readlines). - Just .close() the object when you're done with it. - - If the etag argument is supplied, it will be used as the value of an - If-None-Match request header. - - If the modified argument is supplied, it must be a tuple of 9 integers - as returned by gmtime() in the standard Python time module. This MUST - be in GMT (Greenwich Mean Time). The formatted date/time will be used - as the value of an If-Modified-Since request header. - - If the agent argument is supplied, it will be used as the value of a - User-Agent request header. - - If the referrer argument is supplied, it will be used as the value of a - Referer[sic] request header. - - If handlers is supplied, it is a list of handlers used to build a - urllib2 opener. - """ - - if hasattr(url_file_stream_or_string, 'read'): - return url_file_stream_or_string - - if url_file_stream_or_string == '-': - return sys.stdin - - if urlparse.urlparse(url_file_stream_or_string)[0] in ('http', 'https', 'ftp'): - if not agent: - agent = USER_AGENT - # test for inline user:password for basic auth - auth = None - if base64: - urltype, rest = urllib.splittype(url_file_stream_or_string) - realhost, rest = urllib.splithost(rest) - if realhost: - user_passwd, realhost = urllib.splituser(realhost) - if user_passwd: - url_file_stream_or_string = '%s://%s%s' % (urltype, realhost, rest) - auth = base64.encodestring(user_passwd).strip() - # try to open with urllib2 (to use optional headers) - request = urllib2.Request(url_file_stream_or_string) - request.add_header('User-Agent', agent) - if etag: - request.add_header('If-None-Match', etag) - if modified: - # format into an RFC 1123-compliant timestamp. We can't use - # time.strftime() since the %a and %b directives can be affected - # by the current locale, but RFC 2616 states that dates must be - # in English. - short_weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - request.add_header('If-Modified-Since', '%s, %02d %s %04d %02d:%02d:%02d GMT' % (short_weekdays[modified[6]], modified[2], months[modified[1] - 1], modified[0], modified[3], modified[4], modified[5])) - if referrer: - request.add_header('Referer', referrer) - if gzip and zlib: - request.add_header('Accept-encoding', 'gzip, deflate') - elif gzip: - request.add_header('Accept-encoding', 'gzip') - elif zlib: - request.add_header('Accept-encoding', 'deflate') - else: - request.add_header('Accept-encoding', '') - if auth: - request.add_header('Authorization', 'Basic %s' % auth) - if ACCEPT_HEADER: - request.add_header('Accept', ACCEPT_HEADER) - request.add_header('A-IM', 'feed') # RFC 3229 support - opener = apply(urllib2.build_opener, tuple([_FeedURLHandler()] + handlers)) - opener.addheaders = [] # RMK - must clear so we only send our custom User-Agent - try: - return opener.open(request) - finally: - opener.close() # JohnD - - # try to open with native open function (if url_file_stream_or_string is a filename) - try: - return open(url_file_stream_or_string) - except: - pass - - # treat url_file_stream_or_string as string - return _StringIO(str(url_file_stream_or_string)) - -_date_handlers = [] -def registerDateHandler(func): - '''Register a date handler function (takes string, returns 9-tuple date in GMT)''' - _date_handlers.insert(0, func) - -# ISO-8601 date parsing routines written by Fazal Majid. -# The ISO 8601 standard is very convoluted and irregular - a full ISO 8601 -# parser is beyond the scope of feedparser and would be a worthwhile addition -# to the Python library. -# A single regular expression cannot parse ISO 8601 date formats into groups -# as the standard is highly irregular (for instance is 030104 2003-01-04 or -# 0301-04-01), so we use templates instead. -# Please note the order in templates is significant because we need a -# greedy match. -_iso8601_tmpl = ['YYYY-?MM-?DD', 'YYYY-MM', 'YYYY-?OOO', - 'YY-?MM-?DD', 'YY-?OOO', 'YYYY', - '-YY-?MM', '-OOO', '-YY', - '--MM-?DD', '--MM', - '---DD', - 'CC', ''] -_iso8601_re = [ - tmpl.replace( - 'YYYY', r'(?P\d{4})').replace( - 'YY', r'(?P\d\d)').replace( - 'MM', r'(?P[01]\d)').replace( - 'DD', r'(?P[0123]\d)').replace( - 'OOO', r'(?P[0123]\d\d)').replace( - 'CC', r'(?P\d\d$)') - + r'(T?(?P\d{2}):(?P\d{2})' - + r'(:(?P\d{2}))?' - + r'(?P[+-](?P\d{2})(:(?P\d{2}))?|Z)?)?' - for tmpl in _iso8601_tmpl] -del tmpl -_iso8601_matches = [re.compile(regex).match for regex in _iso8601_re] -del regex -def _parse_date_iso8601(dateString): - '''Parse a variety of ISO-8601-compatible formats like 20040105''' - m = None - for _iso8601_match in _iso8601_matches: - m = _iso8601_match(dateString) - if m: break - if not m: return - if m.span() == (0, 0): return - params = m.groupdict() - ordinal = params.get('ordinal', 0) - if ordinal: - ordinal = int(ordinal) - else: - ordinal = 0 - year = params.get('year', '--') - if not year or year == '--': - year = time.gmtime()[0] - elif len(year) == 2: - # ISO 8601 assumes current century, i.e. 93 -> 2093, NOT 1993 - year = 100 * int(time.gmtime()[0] / 100) + int(year) - else: - year = int(year) - month = params.get('month', '-') - if not month or month == '-': - # ordinals are NOT normalized by mktime, we simulate them - # by setting month=1, day=ordinal - if ordinal: - month = 1 - else: - month = time.gmtime()[1] - month = int(month) - day = params.get('day', 0) - if not day: - # see above - if ordinal: - day = ordinal - elif params.get('century', 0) or \ - params.get('year', 0) or params.get('month', 0): - day = 1 - else: - day = time.gmtime()[2] - else: - day = int(day) - # special case of the century - is the first year of the 21st century - # 2000 or 2001 ? The debate goes on... - if 'century' in params.keys(): - year = (int(params['century']) - 1) * 100 + 1 - # in ISO 8601 most fields are optional - for field in ['hour', 'minute', 'second', 'tzhour', 'tzmin']: - if not params.get(field, None): - params[field] = 0 - hour = int(params.get('hour', 0)) - minute = int(params.get('minute', 0)) - second = int(params.get('second', 0)) - # weekday is normalized by mktime(), we can ignore it - weekday = 0 - # daylight savings is complex, but not needed for feedparser's purposes - # as time zones, if specified, include mention of whether it is active - # (e.g. PST vs. PDT, CET). Using -1 is implementation-dependent and - # and most implementations have DST bugs - daylight_savings_flag = 0 - tm = [year, month, day, hour, minute, second, weekday, - ordinal, daylight_savings_flag] - # ISO 8601 time zone adjustments - tz = params.get('tz') - if tz and tz != 'Z': - if tz[0] == '-': - tm[3] += int(params.get('tzhour', 0)) - tm[4] += int(params.get('tzmin', 0)) - elif tz[0] == '+': - tm[3] -= int(params.get('tzhour', 0)) - tm[4] -= int(params.get('tzmin', 0)) - else: - return None - # Python's time.mktime() is a wrapper around the ANSI C mktime(3c) - # which is guaranteed to normalize d/m/y/h/m/s. - # Many implementations have bugs, but we'll pretend they don't. - return time.localtime(time.mktime(tm)) -registerDateHandler(_parse_date_iso8601) - -# 8-bit date handling routines written by ytrewq1. -_korean_year = u'\ub144' # b3e2 in euc-kr -_korean_month = u'\uc6d4' # bff9 in euc-kr -_korean_day = u'\uc77c' # c0cf in euc-kr -_korean_am = u'\uc624\uc804' # bfc0 c0fc in euc-kr -_korean_pm = u'\uc624\ud6c4' # bfc0 c8c4 in euc-kr - -_korean_onblog_date_re = \ - re.compile('(\d{4})%s\s+(\d{2})%s\s+(\d{2})%s\s+(\d{2}):(\d{2}):(\d{2})' % \ - (_korean_year, _korean_month, _korean_day)) -_korean_nate_date_re = \ - re.compile(u'(\d{4})-(\d{2})-(\d{2})\s+(%s|%s)\s+(\d{,2}):(\d{,2}):(\d{,2})' % \ - (_korean_am, _korean_pm)) -def _parse_date_onblog(dateString): - '''Parse a string according to the OnBlog 8-bit date format''' - m = _korean_onblog_date_re.match(dateString) - if not m: return - w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \ - {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\ - 'hour': m.group(4), 'minute': m.group(5), 'second': m.group(6),\ - 'zonediff': '+09:00'} - if _debug: sys.stderr.write('OnBlog date parsed as: %s\n' % w3dtfdate) - return _parse_date_w3dtf(w3dtfdate) -registerDateHandler(_parse_date_onblog) - -def _parse_date_nate(dateString): - '''Parse a string according to the Nate 8-bit date format''' - m = _korean_nate_date_re.match(dateString) - if not m: return - hour = int(m.group(5)) - ampm = m.group(4) - if (ampm == _korean_pm): - hour += 12 - hour = str(hour) - if len(hour) == 1: - hour = '0' + hour - w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \ - {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\ - 'hour': hour, 'minute': m.group(6), 'second': m.group(7),\ - 'zonediff': '+09:00'} - if _debug: sys.stderr.write('Nate date parsed as: %s\n' % w3dtfdate) - return _parse_date_w3dtf(w3dtfdate) -registerDateHandler(_parse_date_nate) - -_mssql_date_re = \ - re.compile('(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(\.\d+)?') -def _parse_date_mssql(dateString): - '''Parse a string according to the MS SQL date format''' - m = _mssql_date_re.match(dateString) - if not m: return - w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s:%(second)s%(zonediff)s' % \ - {'year': m.group(1), 'month': m.group(2), 'day': m.group(3),\ - 'hour': m.group(4), 'minute': m.group(5), 'second': m.group(6),\ - 'zonediff': '+09:00'} - if _debug: sys.stderr.write('MS SQL date parsed as: %s\n' % w3dtfdate) - return _parse_date_w3dtf(w3dtfdate) -registerDateHandler(_parse_date_mssql) - -# Unicode strings for Greek date strings -_greek_months = \ - { \ - u'\u0399\u03b1\u03bd': u'Jan', # c9e1ed in iso-8859-7 - u'\u03a6\u03b5\u03b2': u'Feb', # d6e5e2 in iso-8859-7 - u'\u039c\u03ac\u03ce': u'Mar', # ccdcfe in iso-8859-7 - u'\u039c\u03b1\u03ce': u'Mar', # cce1fe in iso-8859-7 - u'\u0391\u03c0\u03c1': u'Apr', # c1f0f1 in iso-8859-7 - u'\u039c\u03ac\u03b9': u'May', # ccdce9 in iso-8859-7 - u'\u039c\u03b1\u03ca': u'May', # cce1fa in iso-8859-7 - u'\u039c\u03b1\u03b9': u'May', # cce1e9 in iso-8859-7 - u'\u0399\u03bf\u03cd\u03bd': u'Jun', # c9effded in iso-8859-7 - u'\u0399\u03bf\u03bd': u'Jun', # c9efed in iso-8859-7 - u'\u0399\u03bf\u03cd\u03bb': u'Jul', # c9effdeb in iso-8859-7 - u'\u0399\u03bf\u03bb': u'Jul', # c9f9eb in iso-8859-7 - u'\u0391\u03cd\u03b3': u'Aug', # c1fde3 in iso-8859-7 - u'\u0391\u03c5\u03b3': u'Aug', # c1f5e3 in iso-8859-7 - u'\u03a3\u03b5\u03c0': u'Sep', # d3e5f0 in iso-8859-7 - u'\u039f\u03ba\u03c4': u'Oct', # cfeaf4 in iso-8859-7 - u'\u039d\u03bf\u03ad': u'Nov', # cdefdd in iso-8859-7 - u'\u039d\u03bf\u03b5': u'Nov', # cdefe5 in iso-8859-7 - u'\u0394\u03b5\u03ba': u'Dec', # c4e5ea in iso-8859-7 - } - -_greek_wdays = \ - { \ - u'\u039a\u03c5\u03c1': u'Sun', # caf5f1 in iso-8859-7 - u'\u0394\u03b5\u03c5': u'Mon', # c4e5f5 in iso-8859-7 - u'\u03a4\u03c1\u03b9': u'Tue', # d4f1e9 in iso-8859-7 - u'\u03a4\u03b5\u03c4': u'Wed', # d4e5f4 in iso-8859-7 - u'\u03a0\u03b5\u03bc': u'Thu', # d0e5ec in iso-8859-7 - u'\u03a0\u03b1\u03c1': u'Fri', # d0e1f1 in iso-8859-7 - u'\u03a3\u03b1\u03b2': u'Sat', # d3e1e2 in iso-8859-7 - } - -_greek_date_format_re = \ - re.compile(u'([^,]+),\s+(\d{2})\s+([^\s]+)\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+([^\s]+)') - -def _parse_date_greek(dateString): - '''Parse a string according to a Greek 8-bit date format.''' - m = _greek_date_format_re.match(dateString) - if not m: return - try: - wday = _greek_wdays[m.group(1)] - month = _greek_months[m.group(3)] - except: - return - rfc822date = '%(wday)s, %(day)s %(month)s %(year)s %(hour)s:%(minute)s:%(second)s %(zonediff)s' % \ - {'wday': wday, 'day': m.group(2), 'month': month, 'year': m.group(4),\ - 'hour': m.group(5), 'minute': m.group(6), 'second': m.group(7),\ - 'zonediff': m.group(8)} - if _debug: sys.stderr.write('Greek date parsed as: %s\n' % rfc822date) - return _parse_date_rfc822(rfc822date) -registerDateHandler(_parse_date_greek) - -# Unicode strings for Hungarian date strings -_hungarian_months = \ - { \ - u'janu\u00e1r': u'01', # e1 in iso-8859-2 - u'febru\u00e1ri': u'02', # e1 in iso-8859-2 - u'm\u00e1rcius': u'03', # e1 in iso-8859-2 - u'\u00e1prilis': u'04', # e1 in iso-8859-2 - u'm\u00e1ujus': u'05', # e1 in iso-8859-2 - u'j\u00fanius': u'06', # fa in iso-8859-2 - u'j\u00falius': u'07', # fa in iso-8859-2 - u'augusztus': u'08', - u'szeptember': u'09', - u'okt\u00f3ber': u'10', # f3 in iso-8859-2 - u'november': u'11', - u'december': u'12', - } - -_hungarian_date_format_re = \ - re.compile(u'(\d{4})-([^-]+)-(\d{,2})T(\d{,2}):(\d{2})((\+|-)(\d{,2}:\d{2}))') - -def _parse_date_hungarian(dateString): - '''Parse a string according to a Hungarian 8-bit date format.''' - m = _hungarian_date_format_re.match(dateString) - if not m: return - try: - month = _hungarian_months[m.group(2)] - day = m.group(3) - if len(day) == 1: - day = '0' + day - hour = m.group(4) - if len(hour) == 1: - hour = '0' + hour - except: - return - w3dtfdate = '%(year)s-%(month)s-%(day)sT%(hour)s:%(minute)s%(zonediff)s' % \ - {'year': m.group(1), 'month': month, 'day': day,\ - 'hour': hour, 'minute': m.group(5),\ - 'zonediff': m.group(6)} - if _debug: sys.stderr.write('Hungarian date parsed as: %s\n' % w3dtfdate) - return _parse_date_w3dtf(w3dtfdate) -registerDateHandler(_parse_date_hungarian) - -# W3DTF-style date parsing adapted from PyXML xml.utils.iso8601, written by -# Drake and licensed under the Python license. Removed all range checking -# for month, day, hour, minute, and second, since mktime will normalize -# these later -def _parse_date_w3dtf(dateString): - def __extract_date(m): - year = int(m.group('year')) - if year < 100: - year = 100 * int(time.gmtime()[0] / 100) + int(year) - if year < 1000: - return 0, 0, 0 - julian = m.group('julian') - if julian: - julian = int(julian) - month = julian / 30 + 1 - day = julian % 30 + 1 - jday = None - while jday != julian: - t = time.mktime((year, month, day, 0, 0, 0, 0, 0, 0)) - jday = time.gmtime(t)[-2] - diff = abs(jday - julian) - if jday > julian: - if diff < day: - day = day - diff - else: - month = month - 1 - day = 31 - elif jday < julian: - if day + diff < 28: - day = day + diff - else: - month = month + 1 - return year, month, day - month = m.group('month') - day = 1 - if month is None: - month = 1 - else: - month = int(month) - day = m.group('day') - if day: - day = int(day) - else: - day = 1 - return year, month, day - - def __extract_time(m): - if not m: - return 0, 0, 0 - hours = m.group('hours') - if not hours: - return 0, 0, 0 - hours = int(hours) - minutes = int(m.group('minutes')) - seconds = m.group('seconds') - if seconds: - seconds = int(seconds) - else: - seconds = 0 - return hours, minutes, seconds - - def __extract_tzd(m): - '''Return the Time Zone Designator as an offset in seconds from UTC.''' - if not m: - return 0 - tzd = m.group('tzd') - if not tzd: - return 0 - if tzd == 'Z': - return 0 - hours = int(m.group('tzdhours')) - minutes = m.group('tzdminutes') - if minutes: - minutes = int(minutes) - else: - minutes = 0 - offset = (hours*60 + minutes) * 60 - if tzd[0] == '+': - return -offset - return offset - - __date_re = ('(?P\d\d\d\d)' - '(?:(?P-|)' - '(?:(?P\d\d\d)' - '|(?P\d\d)(?:(?P=dsep)(?P\d\d))?))?') - __tzd_re = '(?P[-+](?P\d\d)(?::?(?P\d\d))|Z)' - __tzd_rx = re.compile(__tzd_re) - __time_re = ('(?P\d\d)(?P:|)(?P\d\d)' - '(?:(?P=tsep)(?P\d\d(?:[.,]\d+)?))?' - + __tzd_re) - __datetime_re = '%s(?:T%s)?' % (__date_re, __time_re) - __datetime_rx = re.compile(__datetime_re) - m = __datetime_rx.match(dateString) - if (m is None) or (m.group() != dateString): return - gmt = __extract_date(m) + __extract_time(m) + (0, 0, 0) - if gmt[0] == 0: return - return time.gmtime(time.mktime(gmt) + __extract_tzd(m) - time.timezone) -registerDateHandler(_parse_date_w3dtf) - -def _parse_date_rfc822(dateString): - '''Parse an RFC822, RFC1123, RFC2822, or asctime-style date''' - data = dateString.split() - if data[0][-1] in (',', '.') or data[0].lower() in rfc822._daynames: - del data[0] - if len(data) == 4: - s = data[3] - i = s.find('+') - if i > 0: - data[3:] = [s[:i], s[i+1:]] - else: - data.append('') - dateString = " ".join(data) - if len(data) < 5: - dateString += ' 00:00:00 GMT' - tm = rfc822.parsedate_tz(dateString) - if tm: - return time.gmtime(rfc822.mktime_tz(tm)) -# rfc822.py defines several time zones, but we define some extra ones. -# 'ET' is equivalent to 'EST', etc. -_additional_timezones = {'AT': -400, 'ET': -500, 'CT': -600, 'MT': -700, 'PT': -800} -rfc822._timezones.update(_additional_timezones) -registerDateHandler(_parse_date_rfc822) - -def _parse_date(dateString): - '''Parses a variety of date formats into a 9-tuple in GMT''' - for handler in _date_handlers: - try: - date9tuple = handler(dateString) - if not date9tuple: continue - if len(date9tuple) != 9: - if _debug: sys.stderr.write('date handler function must return 9-tuple\n') - raise ValueError - map(int, date9tuple) - return date9tuple - except Exception, e: - if _debug: sys.stderr.write('%s raised %s\n' % (handler.__name__, repr(e))) - pass - return None - -def _getCharacterEncoding(http_headers, xml_data): - '''Get the character encoding of the XML document - - http_headers is a dictionary - xml_data is a raw string (not Unicode) - - This is so much trickier than it sounds, it's not even funny. - According to RFC 3023 ('XML Media Types'), if the HTTP Content-Type - is application/xml, application/*+xml, - application/xml-external-parsed-entity, or application/xml-dtd, - the encoding given in the charset parameter of the HTTP Content-Type - takes precedence over the encoding given in the XML prefix within the - document, and defaults to 'utf-8' if neither are specified. But, if - the HTTP Content-Type is text/xml, text/*+xml, or - text/xml-external-parsed-entity, the encoding given in the XML prefix - within the document is ALWAYS IGNORED and only the encoding given in - the charset parameter of the HTTP Content-Type header should be - respected, and it defaults to 'us-ascii' if not specified. - - Furthermore, discussion on the atom-syntax mailing list with the - author of RFC 3023 leads me to the conclusion that any document - served with a Content-Type of text/* and no charset parameter - must be treated as us-ascii. (We now do this.) And also that it - must always be flagged as non-well-formed. (We now do this too.) - - If Content-Type is unspecified (input was local file or non-HTTP source) - or unrecognized (server just got it totally wrong), then go by the - encoding given in the XML prefix of the document and default to - 'iso-8859-1' as per the HTTP specification (RFC 2616). - - Then, assuming we didn't find a character encoding in the HTTP headers - (and the HTTP Content-type allowed us to look in the body), we need - to sniff the first few bytes of the XML data and try to determine - whether the encoding is ASCII-compatible. Section F of the XML - specification shows the way here: - http://www.w3.org/TR/REC-xml/#sec-guessing-no-ext-info - - If the sniffed encoding is not ASCII-compatible, we need to make it - ASCII compatible so that we can sniff further into the XML declaration - to find the encoding attribute, which will tell us the true encoding. - - Of course, none of this guarantees that we will be able to parse the - feed in the declared character encoding (assuming it was declared - correctly, which many are not). CJKCodecs and iconv_codec help a lot; - you should definitely install them if you can. - http://cjkpython.i18n.org/ - ''' - - def _parseHTTPContentType(content_type): - '''takes HTTP Content-Type header and returns (content type, charset) - - If no charset is specified, returns (content type, '') - If no content type is specified, returns ('', '') - Both return parameters are guaranteed to be lowercase strings - ''' - content_type = content_type or '' - content_type, params = cgi.parse_header(content_type) - return content_type, params.get('charset', '').replace("'", '') - - sniffed_xml_encoding = '' - xml_encoding = '' - true_encoding = '' - http_content_type, http_encoding = _parseHTTPContentType(http_headers.get('content-type')) - # Must sniff for non-ASCII-compatible character encodings before - # searching for XML declaration. This heuristic is defined in - # section F of the XML specification: - # http://www.w3.org/TR/REC-xml/#sec-guessing-no-ext-info - try: - if xml_data[:4] == '\x4c\x6f\xa7\x94': - # EBCDIC - xml_data = _ebcdic_to_ascii(xml_data) - elif xml_data[:4] == '\x00\x3c\x00\x3f': - # UTF-16BE - sniffed_xml_encoding = 'utf-16be' - xml_data = unicode(xml_data, 'utf-16be').encode('utf-8') - elif (len(xml_data) >= 4) and (xml_data[:2] == '\xfe\xff') and (xml_data[2:4] != '\x00\x00'): - # UTF-16BE with BOM - sniffed_xml_encoding = 'utf-16be' - xml_data = unicode(xml_data[2:], 'utf-16be').encode('utf-8') - elif xml_data[:4] == '\x3c\x00\x3f\x00': - # UTF-16LE - sniffed_xml_encoding = 'utf-16le' - xml_data = unicode(xml_data, 'utf-16le').encode('utf-8') - elif (len(xml_data) >= 4) and (xml_data[:2] == '\xff\xfe') and (xml_data[2:4] != '\x00\x00'): - # UTF-16LE with BOM - sniffed_xml_encoding = 'utf-16le' - xml_data = unicode(xml_data[2:], 'utf-16le').encode('utf-8') - elif xml_data[:4] == '\x00\x00\x00\x3c': - # UTF-32BE - sniffed_xml_encoding = 'utf-32be' - xml_data = unicode(xml_data, 'utf-32be').encode('utf-8') - elif xml_data[:4] == '\x3c\x00\x00\x00': - # UTF-32LE - sniffed_xml_encoding = 'utf-32le' - xml_data = unicode(xml_data, 'utf-32le').encode('utf-8') - elif xml_data[:4] == '\x00\x00\xfe\xff': - # UTF-32BE with BOM - sniffed_xml_encoding = 'utf-32be' - xml_data = unicode(xml_data[4:], 'utf-32be').encode('utf-8') - elif xml_data[:4] == '\xff\xfe\x00\x00': - # UTF-32LE with BOM - sniffed_xml_encoding = 'utf-32le' - xml_data = unicode(xml_data[4:], 'utf-32le').encode('utf-8') - elif xml_data[:3] == '\xef\xbb\xbf': - # UTF-8 with BOM - sniffed_xml_encoding = 'utf-8' - xml_data = unicode(xml_data[3:], 'utf-8').encode('utf-8') - else: - # ASCII-compatible - pass - xml_encoding_match = re.compile('^<\?.*encoding=[\'"](.*?)[\'"].*\?>').match(xml_data) - except: - xml_encoding_match = None - if xml_encoding_match: - xml_encoding = xml_encoding_match.groups()[0].lower() - if sniffed_xml_encoding and (xml_encoding in ('iso-10646-ucs-2', 'ucs-2', 'csunicode', 'iso-10646-ucs-4', 'ucs-4', 'csucs4', 'utf-16', 'utf-32', 'utf_16', 'utf_32', 'utf16', 'u16')): - xml_encoding = sniffed_xml_encoding - acceptable_content_type = 0 - application_content_types = ('application/xml', 'application/xml-dtd', 'application/xml-external-parsed-entity') - text_content_types = ('text/xml', 'text/xml-external-parsed-entity') - if (http_content_type in application_content_types) or \ - (http_content_type.startswith('application/') and http_content_type.endswith('+xml')): - acceptable_content_type = 1 - true_encoding = http_encoding or xml_encoding or 'utf-8' - elif (http_content_type in text_content_types) or \ - (http_content_type.startswith('text/')) and http_content_type.endswith('+xml'): - acceptable_content_type = 1 - true_encoding = http_encoding or 'us-ascii' - elif http_content_type.startswith('text/'): - true_encoding = http_encoding or 'us-ascii' - elif http_headers and (not http_headers.has_key('content-type')): - true_encoding = xml_encoding or 'iso-8859-1' - else: - true_encoding = xml_encoding or 'utf-8' - return true_encoding, http_encoding, xml_encoding, sniffed_xml_encoding, acceptable_content_type - -def _toUTF8(data, encoding): - '''Changes an XML data stream on the fly to specify a new encoding - - data is a raw sequence of bytes (not Unicode) that is presumed to be in %encoding already - encoding is a string recognized by encodings.aliases - ''' - if _debug: sys.stderr.write('entering _toUTF8, trying encoding %s\n' % encoding) - # strip Byte Order Mark (if present) - if (len(data) >= 4) and (data[:2] == '\xfe\xff') and (data[2:4] != '\x00\x00'): - if _debug: - sys.stderr.write('stripping BOM\n') - if encoding != 'utf-16be': - sys.stderr.write('trying utf-16be instead\n') - encoding = 'utf-16be' - data = data[2:] - elif (len(data) >= 4) and (data[:2] == '\xff\xfe') and (data[2:4] != '\x00\x00'): - if _debug: - sys.stderr.write('stripping BOM\n') - if encoding != 'utf-16le': - sys.stderr.write('trying utf-16le instead\n') - encoding = 'utf-16le' - data = data[2:] - elif data[:3] == '\xef\xbb\xbf': - if _debug: - sys.stderr.write('stripping BOM\n') - if encoding != 'utf-8': - sys.stderr.write('trying utf-8 instead\n') - encoding = 'utf-8' - data = data[3:] - elif data[:4] == '\x00\x00\xfe\xff': - if _debug: - sys.stderr.write('stripping BOM\n') - if encoding != 'utf-32be': - sys.stderr.write('trying utf-32be instead\n') - encoding = 'utf-32be' - data = data[4:] - elif data[:4] == '\xff\xfe\x00\x00': - if _debug: - sys.stderr.write('stripping BOM\n') - if encoding != 'utf-32le': - sys.stderr.write('trying utf-32le instead\n') - encoding = 'utf-32le' - data = data[4:] - newdata = unicode(data, encoding) - if _debug: sys.stderr.write('successfully converted %s data to unicode\n' % encoding) - declmatch = re.compile('^<\?xml[^>]*?>') - newdecl = '''''' - if declmatch.search(newdata): - newdata = declmatch.sub(newdecl, newdata) - else: - newdata = newdecl + u'\n' + newdata - return newdata.encode('utf-8') - -def _stripDoctype(data): - '''Strips DOCTYPE from XML document, returns (rss_version, stripped_data) - - rss_version may be 'rss091n' or None - stripped_data is the same XML document, minus the DOCTYPE - ''' - entity_pattern = re.compile(r']*?)>', re.MULTILINE) - data = entity_pattern.sub('', data) - doctype_pattern = re.compile(r']*?)>', re.MULTILINE) - doctype_results = doctype_pattern.findall(data) - doctype = doctype_results and doctype_results[0] or '' - if doctype.lower().count('netscape'): - version = 'rss091n' - else: - version = None - data = doctype_pattern.sub('', data) - return version, data - -def parse(url_file_stream_or_string, etag=None, modified=None, agent=None, referrer=None, handlers=[]): - '''Parse a feed from a URL, file, stream, or string''' - result = FeedParserDict() - result['feed'] = FeedParserDict() - result['entries'] = [] - if _XML_AVAILABLE: - result['bozo'] = 0 - if type(handlers) == types.InstanceType: - handlers = [handlers] - try: - f = _open_resource(url_file_stream_or_string, etag, modified, agent, referrer, handlers) - data = f.read() - except Exception, e: - result['bozo'] = 1 - result['bozo_exception'] = e - data = '' - f = None - - # if feed is gzip-compressed, decompress it - if f and data and hasattr(f, 'headers'): - if gzip and f.headers.get('content-encoding', '') == 'gzip': - try: - data = gzip.GzipFile(fileobj=_StringIO(data)).read() - except Exception, e: - # Some feeds claim to be gzipped but they're not, so - # we get garbage. Ideally, we should re-request the - # feed without the 'Accept-encoding: gzip' header, - # but we don't. - result['bozo'] = 1 - result['bozo_exception'] = e - data = '' - elif zlib and f.headers.get('content-encoding', '') == 'deflate': - try: - data = zlib.decompress(data, -zlib.MAX_WBITS) - except Exception, e: - result['bozo'] = 1 - result['bozo_exception'] = e - data = '' - - # save HTTP headers - if hasattr(f, 'info'): - info = f.info() - result['etag'] = info.getheader('ETag') - last_modified = info.getheader('Last-Modified') - if last_modified: - result['modified'] = _parse_date(last_modified) - if hasattr(f, 'url'): - result['href'] = f.url - result['status'] = 200 - if hasattr(f, 'status'): - result['status'] = f.status - if hasattr(f, 'headers'): - result['headers'] = f.headers.dict - if hasattr(f, 'close'): - f.close() - - # there are four encodings to keep track of: - # - http_encoding is the encoding declared in the Content-Type HTTP header - # - xml_encoding is the encoding declared in the ; changed -# project name -#2.5 - 7/25/2003 - MAP - changed to Python license (all contributors agree); -# removed unnecessary urllib code -- urllib2 should always be available anyway; -# return actual url, status, and full HTTP headers (as result['url'], -# result['status'], and result['headers']) if parsing a remote feed over HTTP -- -# this should pass all the HTTP tests at ; -# added the latest namespace-of-the-week for RSS 2.0 -#2.5.1 - 7/26/2003 - RMK - clear opener.addheaders so we only send our custom -# User-Agent (otherwise urllib2 sends two, which confuses some servers) -#2.5.2 - 7/28/2003 - MAP - entity-decode inline xml properly; added support for -# inline and as used in some RSS 2.0 feeds -#2.5.3 - 8/6/2003 - TvdV - patch to track whether we're inside an image or -# textInput, and also to return the character encoding (if specified) -#2.6 - 1/1/2004 - MAP - dc:author support (MarekK); fixed bug tracking -# nested divs within content (JohnD); fixed missing sys import (JohanS); -# fixed regular expression to capture XML character encoding (Andrei); -# added support for Atom 0.3-style links; fixed bug with textInput tracking; -# added support for cloud (MartijnP); added support for multiple -# category/dc:subject (MartijnP); normalize content model: 'description' gets -# description (which can come from description, summary, or full content if no -# description), 'content' gets dict of base/language/type/value (which can come -# from content:encoded, xhtml:body, content, or fullitem); -# fixed bug matching arbitrary Userland namespaces; added xml:base and xml:lang -# tracking; fixed bug tracking unknown tags; fixed bug tracking content when -# element is not in default namespace (like Pocketsoap feed); -# resolve relative URLs in link, guid, docs, url, comments, wfw:comment, -# wfw:commentRSS; resolve relative URLs within embedded HTML markup in -# description, xhtml:body, content, content:encoded, title, subtitle, -# summary, info, tagline, and copyright; added support for pingback and -# trackback namespaces -#2.7 - 1/5/2004 - MAP - really added support for trackback and pingback -# namespaces, as opposed to 2.6 when I said I did but didn't really; -# sanitize HTML markup within some elements; added mxTidy support (if -# installed) to tidy HTML markup within some elements; fixed indentation -# bug in _parse_date (FazalM); use socket.setdefaulttimeout if available -# (FazalM); universal date parsing and normalization (FazalM): 'created', modified', -# 'issued' are parsed into 9-tuple date format and stored in 'created_parsed', -# 'modified_parsed', and 'issued_parsed'; 'date' is duplicated in 'modified' -# and vice-versa; 'date_parsed' is duplicated in 'modified_parsed' and vice-versa -#2.7.1 - 1/9/2004 - MAP - fixed bug handling " and '. fixed memory -# leak not closing url opener (JohnD); added dc:publisher support (MarekK); -# added admin:errorReportsTo support (MarekK); Python 2.1 dict support (MarekK) -#2.7.4 - 1/14/2004 - MAP - added workaround for improperly formed
tags in -# encoded HTML (skadz); fixed unicode handling in normalize_attrs (ChrisL); -# fixed relative URI processing for guid (skadz); added ICBM support; added -# base64 support -#2.7.5 - 1/15/2004 - MAP - added workaround for malformed DOCTYPE (seen on many -# blogspot.com sites); added _debug variable -#2.7.6 - 1/16/2004 - MAP - fixed bug with StringIO importing -#3.0b3 - 1/23/2004 - MAP - parse entire feed with real XML parser (if available); -# added several new supported namespaces; fixed bug tracking naked markup in -# description; added support for enclosure; added support for source; re-added -# support for cloud which got dropped somehow; added support for expirationDate -#3.0b4 - 1/26/2004 - MAP - fixed xml:lang inheritance; fixed multiple bugs tracking -# xml:base URI, one for documents that don't define one explicitly and one for -# documents that define an outer and an inner xml:base that goes out of scope -# before the end of the document -#3.0b5 - 1/26/2004 - MAP - fixed bug parsing multiple links at feed level -#3.0b6 - 1/27/2004 - MAP - added feed type and version detection, result['version'] -# will be one of SUPPORTED_VERSIONS.keys() or empty string if unrecognized; -# added support for creativeCommons:license and cc:license; added support for -# full Atom content model in title, tagline, info, copyright, summary; fixed bug -# with gzip encoding (not always telling server we support it when we do) -#3.0b7 - 1/28/2004 - MAP - support Atom-style author element in author_detail -# (dictionary of 'name', 'url', 'email'); map author to author_detail if author -# contains name + email address -#3.0b8 - 1/28/2004 - MAP - added support for contributor -#3.0b9 - 1/29/2004 - MAP - fixed check for presence of dict function; added -# support for summary -#3.0b10 - 1/31/2004 - MAP - incorporated ISO-8601 date parsing routines from -# xml.util.iso8601 -#3.0b11 - 2/2/2004 - MAP - added 'rights' to list of elements that can contain -# dangerous markup; fiddled with decodeEntities (not right); liberalized -# date parsing even further -#3.0b12 - 2/6/2004 - MAP - fiddled with decodeEntities (still not right); -# added support to Atom 0.2 subtitle; added support for Atom content model -# in copyright; better sanitizing of dangerous HTML elements with end tags -# (script, frameset) -#3.0b13 - 2/8/2004 - MAP - better handling of empty HTML tags (br, hr, img, -# etc.) in embedded markup, in either HTML or XHTML form (
,
,
) -#3.0b14 - 2/8/2004 - MAP - fixed CDATA handling in non-wellformed feeds under -# Python 2.1 -#3.0b15 - 2/11/2004 - MAP - fixed bug resolving relative links in wfw:commentRSS; -# fixed bug capturing author and contributor URL; fixed bug resolving relative -# links in author and contributor URL; fixed bug resolvin relative links in -# generator URL; added support for recognizing RSS 1.0; passed Simon Fell's -# namespace tests, and included them permanently in the test suite with his -# permission; fixed namespace handling under Python 2.1 -#3.0b16 - 2/12/2004 - MAP - fixed support for RSS 0.90 (broken in b15) -#3.0b17 - 2/13/2004 - MAP - determine character encoding as per RFC 3023 -#3.0b18 - 2/17/2004 - MAP - always map description to summary_detail (Andrei); -# use libxml2 (if available) -#3.0b19 - 3/15/2004 - MAP - fixed bug exploding author information when author -# name was in parentheses; removed ultra-problematic mxTidy support; patch to -# workaround crash in PyXML/expat when encountering invalid entities -# (MarkMoraes); support for textinput/textInput -#3.0b20 - 4/7/2004 - MAP - added CDF support -#3.0b21 - 4/14/2004 - MAP - added Hot RSS support -#3.0b22 - 4/19/2004 - MAP - changed 'channel' to 'feed', 'item' to 'entries' in -# results dict; changed results dict to allow getting values with results.key -# as well as results[key]; work around embedded illformed HTML with half -# a DOCTYPE; work around malformed Content-Type header; if character encoding -# is wrong, try several common ones before falling back to regexes (if this -# works, bozo_exception is set to CharacterEncodingOverride); fixed character -# encoding issues in BaseHTMLProcessor by tracking encoding and converting -# from Unicode to raw strings before feeding data to sgmllib.SGMLParser; -# convert each value in results to Unicode (if possible), even if using -# regex-based parsing -#3.0b23 - 4/21/2004 - MAP - fixed UnicodeDecodeError for feeds that contain -# high-bit characters in attributes in embedded HTML in description (thanks -# Thijs van de Vossen); moved guid, date, and date_parsed to mapped keys in -# FeedParserDict; tweaked FeedParserDict.has_key to return True if asking -# about a mapped key -#3.0fc1 - 4/23/2004 - MAP - made results.entries[0].links[0] and -# results.entries[0].enclosures[0] into FeedParserDict; fixed typo that could -# cause the same encoding to be tried twice (even if it failed the first time); -# fixed DOCTYPE stripping when DOCTYPE contained entity declarations; -# better textinput and image tracking in illformed RSS 1.0 feeds -#3.0fc2 - 5/10/2004 - MAP - added and passed Sam's amp tests; added and passed -# my blink tag tests -#3.0fc3 - 6/18/2004 - MAP - fixed bug in _changeEncodingDeclaration that -# failed to parse utf-16 encoded feeds; made source into a FeedParserDict; -# duplicate admin:generatorAgent/@rdf:resource in generator_detail.url; -# added support for image; refactored parse() fallback logic to try other -# encodings if SAX parsing fails (previously it would only try other encodings -# if re-encoding failed); remove unichr madness in normalize_attrs now that -# we're properly tracking encoding in and out of BaseHTMLProcessor; set -# feed.language from root-level xml:lang; set entry.id from rdf:about; -# send Accept header -#3.0 - 6/21/2004 - MAP - don't try iso-8859-1 (can't distinguish between -# iso-8859-1 and windows-1252 anyway, and most incorrectly marked feeds are -# windows-1252); fixed regression that could cause the same encoding to be -# tried twice (even if it failed the first time) -#3.0.1 - 6/22/2004 - MAP - default to us-ascii for all text/* content types; -# recover from malformed content-type header parameter with no equals sign -# ('text/xml; charset:iso-8859-1') -#3.1 - 6/28/2004 - MAP - added and passed tests for converting HTML entities -# to Unicode equivalents in illformed feeds (aaronsw); added and -# passed tests for converting character entities to Unicode equivalents -# in illformed feeds (aaronsw); test for valid parsers when setting -# XML_AVAILABLE; make version and encoding available when server returns -# a 304; add handlers parameter to pass arbitrary urllib2 handlers (like -# digest auth or proxy support); add code to parse username/password -# out of url and send as basic authentication; expose downloading-related -# exceptions in bozo_exception (aaronsw); added __contains__ method to -# FeedParserDict (aaronsw); added publisher_detail (aaronsw) -#3.2 - 7/3/2004 - MAP - use cjkcodecs and iconv_codec if available; always -# convert feed to UTF-8 before passing to XML parser; completely revamped -# logic for determining character encoding and attempting XML parsing -# (much faster); increased default timeout to 20 seconds; test for presence -# of Location header on redirects; added tests for many alternate character -# encodings; support various EBCDIC encodings; support UTF-16BE and -# UTF16-LE with or without a BOM; support UTF-8 with a BOM; support -# UTF-32BE and UTF-32LE with or without a BOM; fixed crashing bug if no -# XML parsers are available; added support for 'Content-encoding: deflate'; -# send blank 'Accept-encoding: ' header if neither gzip nor zlib modules -# are available -#3.3 - 7/15/2004 - MAP - optimize EBCDIC to ASCII conversion; fix obscure -# problem tracking xml:base and xml:lang if element declares it, child -# doesn't, first grandchild redeclares it, and second grandchild doesn't; -# refactored date parsing; defined public registerDateHandler so callers -# can add support for additional date formats at runtime; added support -# for OnBlog, Nate, MSSQL, Greek, and Hungarian dates (ytrewq1); added -# zopeCompatibilityHack() which turns FeedParserDict into a regular -# dictionary, required for Zope compatibility, and also makes command- -# line debugging easier because pprint module formats real dictionaries -# better than dictionary-like objects; added NonXMLContentType exception, -# which is stored in bozo_exception when a feed is served with a non-XML -# media type such as 'text/plain'; respect Content-Language as default -# language if not xml:lang is present; cloud dict is now FeedParserDict; -# generator dict is now FeedParserDict; better tracking of xml:lang, -# including support for xml:lang='' to unset the current language; -# recognize RSS 1.0 feeds even when RSS 1.0 namespace is not the default -# namespace; don't overwrite final status on redirects (scenarios: -# redirecting to a URL that returns 304, redirecting to a URL that -# redirects to another URL with a different type of redirect); add -# support for HTTP 303 redirects -#4.0 - MAP - support for relative URIs in xml:base attribute; fixed -# encoding issue with mxTidy (phopkins); preliminary support for RFC 3229; -# support for Atom 1.0; support for iTunes extensions; new 'tags' for -# categories/keywords/etc. as array of dict -# {'term': term, 'scheme': scheme, 'label': label} to match Atom 1.0 -# terminology; parse RFC 822-style dates with no time; lots of other -# bug fixes -#4.1 - MAP - removed socket timeout; added support for chardet library diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index a3b5cebe6..e2fd5eeca 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -33,6 +33,7 @@ import time import socket import sgmllib import threading +import feedparser import supybot.conf as conf import supybot.utils as utils @@ -42,13 +43,6 @@ import supybot.ircutils as ircutils import supybot.registry as registry import supybot.callbacks as callbacks -try: - feedparser = utils.python.universalImport('feedparser', 'local.feedparser') -except ImportError: - raise callbacks.Error, \ - 'You the feedparser module installed to use this plugin. ' \ - 'Download the module at .' - def getFeedName(irc, msg, args, state): if not registry.isValidRegistryName(args[0]): state.errorInvalid('feed name', args[0], diff --git a/plugins/RSS/test.py b/plugins/RSS/test.py index 5e9dea1c2..6dde2e9a2 100644 --- a/plugins/RSS/test.py +++ b/plugins/RSS/test.py @@ -77,7 +77,6 @@ class RSSTestCase(ChannelPluginTestCase): def testNonAsciiFeeds(self): self.assertNotError('rss http://www.heise.de/newsticker/heise.rdf') - self.assertNotError('rss http://www.golem.de/rss.php?feed=ATOM0.3') self.assertNotError('rss info http://br-linux.org/main/index.xml') diff --git a/setup.py b/setup.py index 905d42c9c..590a493d7 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,6 @@ packages = ['supybot', [ 'supybot.plugins.Dict.local', 'supybot.plugins.Math.local', - 'supybot.plugins.RSS.local', ] package_dir = {'supybot': 'src', @@ -75,7 +74,6 @@ package_dir = {'supybot': 'src', 'supybot.drivers': 'src/drivers', 'supybot.plugins.Dict.local': 'plugins/Dict/local', 'supybot.plugins.Math.local': 'plugins/Math/local', - 'supybot.plugins.RSS.local': 'plugins/RSS/local', } for plugin in plugins: @@ -128,6 +126,7 @@ setup( install_requires=[ # Time plugin 'python-dateutil <2.0,>=1.3', + 'feedparser', ], ) From 6025f7364c6314d71326db6f956c81a4afccd50b Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 31 May 2012 22:53:21 -0400 Subject: [PATCH 13/67] core: force inet_aton argument to string to prevent occasional error on reconnect. it /should/ always be a string anyway, but sometimes things break with a TypeError that it is an int instead of the expected string and hangs up the bot. Signed-off-by: James McCoy --- src/utils/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/net.py b/src/utils/net.py index 0e657e9f0..470949f25 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -73,7 +73,7 @@ def isIPV4(s): 0 """ try: - return bool(socket.inet_aton(s)) + return bool(socket.inet_aton(str(s))) except socket.error: return False From 7feb50685a34ee71ccfefd06c13cae0cf0f98eed Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 1 Jul 2010 15:44:53 -0400 Subject: [PATCH 14/67] Filter: add unbinary command, as counterpart to binary command. Signed-off-by: James McCoy --- plugins/Filter/plugin.py | 10 ++++++++++ plugins/Filter/test.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index 9cc264465..fcc247116 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -156,6 +156,16 @@ class Filter(callbacks.Plugin): irc.reply(''.join(L)) binary = wrap(binary, ['text']) + def unbinary(self, irc, msg, args, text): + """ + + Returns the character representation of binary . + Assumes ASCII, 8 digits per character. + """ + L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] + irc.reply(''.join(L)) + unbinary = wrap(unbinary, ['text']) + def hexlify(self, irc, msg, args, text): """ diff --git a/plugins/Filter/test.py b/plugins/Filter/test.py index d31959f66..2a73e5f9f 100644 --- a/plugins/Filter/test.py +++ b/plugins/Filter/test.py @@ -87,6 +87,9 @@ class FilterTest(ChannelPluginTestCase): def testBinary(self): self.assertResponse('binary A', '01000001') + def testUnbinary(self): + self.assertResponse('unbinary 011011010110111101101111', 'moo') + def testRot13(self): for s in map(str, range(1000, 1010)): self.assertResponse('rot13 [rot13 %s]' % s, s) From 84b878b10eeb0ef68faa4417a66e2e21a72e996e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 21 Jul 2010 12:57:18 -0400 Subject: [PATCH 15/67] Filter: catch invalid input for unbinary command. Signed-off-by: James McCoy --- plugins/Filter/plugin.py | 9 ++++++--- plugins/Filter/test.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/Filter/plugin.py b/plugins/Filter/plugin.py index fcc247116..e995aa125 100644 --- a/plugins/Filter/plugin.py +++ b/plugins/Filter/plugin.py @@ -158,12 +158,15 @@ class Filter(callbacks.Plugin): def unbinary(self, irc, msg, args, text): """ - + Returns the character representation of binary . Assumes ASCII, 8 digits per character. """ - L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] - irc.reply(''.join(L)) + try: + L = [chr(int(text[i:(i+8)], 2)) for i in xrange(0, len(text), 8)] + irc.reply(''.join(L)) + except ValueError: + irc.errorInvalid('binary string', text) unbinary = wrap(unbinary, ['text']) def hexlify(self, irc, msg, args, text): diff --git a/plugins/Filter/test.py b/plugins/Filter/test.py index 2a73e5f9f..0a66da62b 100644 --- a/plugins/Filter/test.py +++ b/plugins/Filter/test.py @@ -89,6 +89,7 @@ class FilterTest(ChannelPluginTestCase): def testUnbinary(self): self.assertResponse('unbinary 011011010110111101101111', 'moo') + self.assertError('unbinary moo') def testRot13(self): for s in map(str, range(1000, 1010)): From d691a916363391f18161892e6f64bfcad91b701b Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 23 Jul 2010 16:50:25 -0400 Subject: [PATCH 16/67] Topic: fix bug in invalid number error output. Previously, when giving an invalid positive number, error would reference number-1 as being invalid. Signed-off-by: James McCoy --- plugins/Topic/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index 00700033b..f1b109d57 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -89,7 +89,7 @@ def getTopicNumber(irc, msg, args, state): try: topics[n] except IndexError: - error(str(n)) + error(args[0]) del args[0] while n < 0: n += len(topics) From a2985c37d69ffb192ada304f4f76299efe5c176e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 01:20:46 -0400 Subject: [PATCH 17/67] Create a commands.process function which runs a function inside a separate process. This is the only way to limit the execution time of a possibly long-running python statement. Use this on String.re, due to the possibility of pathologically long re matching in python. This allows us to remove the 'trusted-only' restriction on string.re. In the future, this should probably be used in other places that take user-supplied regexps, such as 'misc last --regexp', for example, as well as other potentially long-running tasks that can block the bot. Signed-off-by: James McCoy --- plugins/String/plugin.py | 10 +++++----- src/callbacks.py | 17 +++++++++++++++++ src/commands.py | 25 +++++++++++++++++++++++++ src/world.py | 10 ++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index a2fe1517b..12346d4cf 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -34,10 +34,10 @@ import binascii import supybot.utils as utils from supybot.commands import * import supybot.plugins as plugins +import supybot.commands as commands import supybot.ircutils as ircutils import supybot.callbacks as callbacks - class String(callbacks.Plugin): def ord(self, irc, msg, args, letter): """ @@ -141,10 +141,10 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - irc.reply(f(text)) - re = wrap(re, [('checkCapability', 'trusted'), - first('regexpMatcher', 'regexpReplacer'), - 'text']) + v = commands.process(f, text, timeout=10) + irc.reply(v) + re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), + 'text'])) def xor(self, irc, msg, args, password, text): """ diff --git a/src/callbacks.py b/src/callbacks.py index 36ace9b4d..0947cbb01 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -970,6 +970,23 @@ class CommandThread(world.SupyThread): finally: self.cb.threaded = self.originalThreaded +class CommandProcess(world.SupyProcess): + """Just does some extra logging and error-recovery for commands that need + to run in processes. + """ + def __init__(self, target=None, args=(), kwargs={}): + self.command = args[0] + self.cb = target.im_self + procName = 'Process #%s (for %s.%s)' % (world.processesSpawned, + self.cb.name(), + self.command) + log.debug('Spawning process %s (args: %r)', procName, args) + self.__parent = super(CommandProcess, self) + self.__parent.__init__(target=target, name=procName, + args=args, kwargs=kwargs) + + def run(self): + self.__parent.run() class CanonicalString(registry.NormalizedString): def normalize(self, s): diff --git a/src/commands.py b/src/commands.py index cfa1622d5..2d7b0fb5d 100644 --- a/src/commands.py +++ b/src/commands.py @@ -33,10 +33,12 @@ Includes wrappers for commands. """ import time +import Queue import types import getopt import inspect import threading +import multiprocessing from . import callbacks, conf, ircdb, ircmsgs, ircutils, log, utils, world @@ -59,6 +61,29 @@ def thread(f): f(self, irc, msg, args, *L, **kwargs) return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) +def process(f, *args, **kwargs): + """Runs a function in a subprocess. + Takes an extra timeout argument, which, if supplied, limits the length + of execution of target function to seconds.""" + timeout = kwargs.pop('timeout') + q = multiprocessing.Queue() + def newf(f, q, *args, **kwargs): + r = f(*args, **kwargs) + q.put(r) + targetArgs = (f, q,) + args + p = world.SupyProcess(target=newf, + args=targetArgs, kwargs=kwargs) + p.start() + p.join(timeout) + if p.is_alive(): + p.terminate() + q.put("Function call aborted due to timeout.") + try: + v = q.get(block=False) + except Queue.Empty: + v = "Nothing returned." + return v + class UrlSnarfThread(world.SupyThread): def __init__(self, *args, **kwargs): assert 'url' in kwargs diff --git a/src/world.py b/src/world.py index 594b6e994..47bbed775 100644 --- a/src/world.py +++ b/src/world.py @@ -37,6 +37,7 @@ import sys import time import atexit import threading +import multiprocessing if sys.version_info >= (2, 5, 0): import re as sre @@ -63,6 +64,15 @@ class SupyThread(threading.Thread): super(SupyThread, self).__init__(*args, **kwargs) log.debug('Spawning thread %q.', self.getName()) +processesSpawned = 1 # Starts at one for the initial process. +class SupyProcess(multiprocessing.Process): + def __init__(self, *args, **kwargs): + global processesSpawned + processesSpawned += 1 + super(SupyProcess, self).__init__(*args, **kwargs) + log.debug('Spawning process %q.', self.name) + + commandsProcessed = 0 ircs = [] # A list of all the IRCs. From 7f98aa7105784e33669fd4f817ac3ae54d5bd01e Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 13:45:02 -0400 Subject: [PATCH 18/67] Some improvements to the commands.process function - better process naming and informational output. Signed-off-by: James McCoy --- plugins/String/plugin.py | 2 +- src/callbacks.py | 8 ++++---- src/commands.py | 17 +++++++++++------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 12346d4cf..9b4719489 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -141,7 +141,7 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - v = commands.process(f, text, timeout=10) + v = commands.process(f, text, timeout=10, pn=self.name(), cn='re') irc.reply(v) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) diff --git a/src/callbacks.py b/src/callbacks.py index 0947cbb01..be7ebf361 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -975,11 +975,11 @@ class CommandProcess(world.SupyProcess): to run in processes. """ def __init__(self, target=None, args=(), kwargs={}): - self.command = args[0] - self.cb = target.im_self + pn = kwargs.pop('pn', 'Unknown') + cn = kwargs.pop('cn', 'unknown') procName = 'Process #%s (for %s.%s)' % (world.processesSpawned, - self.cb.name(), - self.command) + pn, + cn) log.debug('Spawning process %s (args: %r)', procName, args) self.__parent = super(CommandProcess, self) self.__parent.__init__(target=target, name=procName, diff --git a/src/commands.py b/src/commands.py index 2d7b0fb5d..c7f016370 100644 --- a/src/commands.py +++ b/src/commands.py @@ -62,22 +62,27 @@ def thread(f): return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) def process(f, *args, **kwargs): - """Runs a function in a subprocess. - Takes an extra timeout argument, which, if supplied, limits the length - of execution of target function to seconds.""" - timeout = kwargs.pop('timeout') + """Runs a function in a subprocess. + + Several extra keyword arguments can be supplied. + , the pluginname, and , the command name, are strings used to + create the process name, for identification purposes. + , if supplied, limits the length of execution of target + function to seconds.""" + timeout = kwargs.pop('timeout', None) + q = multiprocessing.Queue() def newf(f, q, *args, **kwargs): r = f(*args, **kwargs) q.put(r) targetArgs = (f, q,) + args - p = world.SupyProcess(target=newf, + p = callbacks.CommandProcess(target=newf, args=targetArgs, kwargs=kwargs) p.start() p.join(timeout) if p.is_alive(): p.terminate() - q.put("Function call aborted due to timeout.") + q.put("%s aborted due to timeout." % (p.name,)) try: v = q.get(block=False) except Queue.Empty: From 7f4a1be9f926651298a9adf681ddb94994dc899f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 13:54:54 -0400 Subject: [PATCH 19/67] Status: add 'processes' command, the multiprocessing equivalent of the threads command. Signed-off-by: James McCoy --- plugins/Status/plugin.py | 12 ++++++++++++ plugins/Status/test.py | 2 ++ 2 files changed, 14 insertions(+) diff --git a/plugins/Status/plugin.py b/plugins/Status/plugin.py index 10ae9d824..1bc0dd2cf 100644 --- a/plugins/Status/plugin.py +++ b/plugins/Status/plugin.py @@ -94,6 +94,18 @@ class Status(callbacks.Plugin): irc.reply(s) threads = wrap(threads) + def processes(self, irc, msg, args): + """takes no arguments + + Returns the number of processes that have been spawned. + """ + # TODO: maintain a dict of active subprocesses, so we can + # include a list thereof in output, linke in threads(). maybe? + s = format('I have spawned %n.', + (world.processesSpawned, 'process')) + irc.reply(s) + processes = wrap(processes) + def net(self, irc, msg, args): """takes no arguments diff --git a/plugins/Status/test.py b/plugins/Status/test.py index 4843c4145..de456b061 100644 --- a/plugins/Status/test.py +++ b/plugins/Status/test.py @@ -71,6 +71,8 @@ class StatusTestCase(PluginTestCase): def testThreads(self): self.assertNotError('threads') + def testProcesses(self): + self.assertNotError('processes') # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: From 141b120a9caa53218b5933db89523dd396c8cd92 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 5 Aug 2010 14:48:12 -0400 Subject: [PATCH 20/67] commands.process: return immediately when terminating process, without having to deal with the queue. otherwise, we have to block for $smalldelay between putting and getting the item, since queue putting is not instantaneous and sometimes we would get 'nothing returned' instead of the timeout message. Signed-off-by: James McCoy --- src/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index c7f016370..fb905ee69 100644 --- a/src/commands.py +++ b/src/commands.py @@ -82,7 +82,7 @@ def process(f, *args, **kwargs): p.join(timeout) if p.is_alive(): p.terminate() - q.put("%s aborted due to timeout." % (p.name,)) + return "%s aborted due to timeout." % (p.name,) try: v = q.get(block=False) except Queue.Empty: From 7504c646b7a0b08695ba8fec1c3b4a3a99f998db Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 6 Aug 2010 14:48:21 -0400 Subject: [PATCH 21/67] Status.processes: add output of currently active processes. Signed-off-by: James McCoy --- plugins/Status/plugin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/Status/plugin.py b/plugins/Status/plugin.py index 1bc0dd2cf..3412bec4e 100644 --- a/plugins/Status/plugin.py +++ b/plugins/Status/plugin.py @@ -32,6 +32,7 @@ import os import sys import time import threading +import multiprocessing import subprocess import supybot.conf as conf @@ -97,12 +98,15 @@ class Status(callbacks.Plugin): def processes(self, irc, msg, args): """takes no arguments - Returns the number of processes that have been spawned. + Returns the number of processes that have been spawned, and list of + ones that are still active. """ - # TODO: maintain a dict of active subprocesses, so we can - # include a list thereof in output, linke in threads(). maybe? - s = format('I have spawned %n.', - (world.processesSpawned, 'process')) + ps = [multiprocessing.current_process().name] + ps = ps + [p.name for p in multiprocessing.active_children()] + s = format('I have spawned %n; %n %b still currently active: %L.', + (world.processesSpawned, 'process'), + (len(ps), 'process'), + len(ps), ps) irc.reply(s) processes = wrap(processes) From 9fc7ec28b44bbaa6d1a8c4687657419857a63e6f Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 00:39:51 -0400 Subject: [PATCH 22/67] Fix error handling for subprocesses. Signed-off-by: James McCoy --- src/commands.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands.py b/src/commands.py index fb905ee69..49d682437 100644 --- a/src/commands.py +++ b/src/commands.py @@ -73,8 +73,11 @@ def process(f, *args, **kwargs): q = multiprocessing.Queue() def newf(f, q, *args, **kwargs): - r = f(*args, **kwargs) - q.put(r) + try: + r = f(*args, **kwargs) + q.put(r) + except Exception as e: + q.put(e) targetArgs = (f, q,) + args p = callbacks.CommandProcess(target=newf, args=targetArgs, kwargs=kwargs) @@ -87,6 +90,8 @@ def process(f, *args, **kwargs): v = q.get(block=False) except Queue.Empty: v = "Nothing returned." + if isinstance(v, Exception): + v = "Error: " + str(v) return v class UrlSnarfThread(world.SupyThread): From 34e5aedc45dfc431324442d22a330d1ad4af68e1 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 01:43:05 -0400 Subject: [PATCH 23/67] String: make re timeout configurable. Signed-off-by: James McCoy --- plugins/String/config.py | 7 +++++++ plugins/String/plugin.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/String/config.py b/plugins/String/config.py index e4f1840ad..3992d9631 100644 --- a/plugins/String/config.py +++ b/plugins/String/config.py @@ -50,5 +50,12 @@ conf.registerGlobalValue(String.levenshtein, 'max', and more time. Using nested commands, strings can get quite large, hence this variable, to limit the size of arguments passed to the levenshtein command.""")) +conf.registerGroup(String, 're') +conf.registerGlobalValue(String.re, 'timeout', + registry.PositiveFloat(5, """Determines the maximum time, in seconds, that + a regular expression is given to execute before being terminated. Since + there is a possibility that user input for the re command can cause it to + eat up large amounts of ram or cpu time, it's a good idea to keep this + low. Most normal regexps should not take very long at all.""")) # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 9b4719489..ef0e2cfac 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -141,7 +141,8 @@ class String(callbacks.Plugin): s = 'You probably don\'t want to match the empty string.' irc.error(s) else: - v = commands.process(f, text, timeout=10, pn=self.name(), cn='re') + t = self.registryValue('re.timeout') + v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') irc.reply(v) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) From 92be7c255f48b4a33f6db1f91b95d694acdf3f35 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Sun, 8 Aug 2010 01:46:05 -0400 Subject: [PATCH 24/67] String: make levenshtein command threaded, since it can take a nontrivial amount of time with longer inputs. Signed-off-by: James McCoy --- plugins/String/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index ef0e2cfac..0aa8bc338 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -105,7 +105,7 @@ class String(callbacks.Plugin): 'it with some smaller inputs.') else: irc.reply(str(utils.str.distance(s1, s2))) - levenshtein = wrap(levenshtein, ['something', 'text']) + levenshtein = thread(wrap(levenshtein, ['something', 'text'])) def soundex(self, irc, msg, args, text, length): """ [] From a5ec33adeba16a68694ab6a6e2ae96dc78da8786 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 11 Aug 2010 00:43:05 -0400 Subject: [PATCH 25/67] Make plugin loading/reloading case-insensitive. Since load/reload was the only place where case mattered for plugins, and it tripped up a lot of new users, this should be a nice bit of usability improvement. Signed-off-by: James McCoy --- src/plugin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/plugin.py b/src/plugin.py index 2eb96b2ae..8ab422ed3 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -32,6 +32,7 @@ import sys import imp import os.path import linecache +import re from . import callbacks, conf, log, registry @@ -52,6 +53,13 @@ def loadPluginModule(name, ignoreDeprecation=False): except EnvironmentError: # OSError, IOError superclass. log.warning('Invalid plugin directory: %s; removing.', dir) conf.supybot.directories.plugins().remove(dir) + if name not in files: + matched_names = filter(lambda x: re.search(r'(?i)^%s$' % (name,), x), + files) + if len(matched_names) == 1: + name = matched_names[0] + else: + raise ImportError, name moduleInfo = imp.find_module(name, pluginDirs) try: module = imp.load_module(name, *moduleInfo) From 011d4dd67687961a0dcc6922e12fb057e4ada151 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 7 Sep 2010 20:27:51 -0400 Subject: [PATCH 26/67] Badwords: add plugin docstring, and fix/standardize some method docstrings. Signed-off-by: James McCoy --- plugins/BadWords/plugin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/BadWords/plugin.py b/plugins/BadWords/plugin.py index 224a1e204..652a0bece 100644 --- a/plugins/BadWords/plugin.py +++ b/plugins/BadWords/plugin.py @@ -41,6 +41,9 @@ import supybot.ircutils as ircutils import supybot.callbacks as callbacks class BadWords(callbacks.Privmsg): + """Maintains a list of words that the bot is not allowed to say. + Can also be used to kick people that say these words, if the bot + has op.""" def __init__(self, irc): self.__parent = super(BadWords, self) self.__parent.__init__(irc) @@ -66,7 +69,7 @@ class BadWords(callbacks.Privmsg): def inFilter(self, irc, msg): self.filtering = True # We need to check for bad words here rather than in doPrivmsg because - # messages don't get to doPrivmsg is the user is ignored. + # messages don't get to doPrivmsg if the user is ignored. if msg.command == 'PRIVMSG': self.updateRegexp() s = ircutils.stripFormatting(msg.args[1]) @@ -120,7 +123,7 @@ class BadWords(callbacks.Privmsg): def add(self, irc, msg, args, words): """ [ ...] - Adds all s to the list of words the bot isn't to say. + Adds all s to the list of words being censored. """ set = self.words() set.update(words) @@ -131,7 +134,7 @@ class BadWords(callbacks.Privmsg): def remove(self, irc, msg, args, words): """ [ ...] - Removes a s from the list of words the bot isn't to say. + Removes s from the list of words being censored. """ set = self.words() for word in words: From 02b9431536af0da58e47440dcca856722fcfbae6 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Wed, 8 Sep 2010 00:11:28 -0400 Subject: [PATCH 27/67] BadWords: improve help for requireWordBoundaries config. Make a note that the plugin requires restart or the words set updating, for changes to this setting to take effect. Signed-off-by: James McCoy --- plugins/BadWords/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/BadWords/config.py b/plugins/BadWords/config.py index d88d0a5f4..d20f983af 100644 --- a/plugins/BadWords/config.py +++ b/plugins/BadWords/config.py @@ -54,7 +54,9 @@ conf.registerGlobalValue(BadWords,'requireWordBoundaries', words to be independent words, or whether it will censor them within other words. For instance, if 'darn' is a bad word, then if this is true, 'darn' will be censored, but 'darnit' will not. You probably want this to be - false.""")) + false. After changing this setting, the BadWords regexp needs to be + regenerated by adding/removing a word to the list, or reloading the + plugin.""")) class String256(registry.String): def __call__(self): From 854e086fa7b70932849a8df81472623309cb339a Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 25 Jan 2011 01:26:42 -0500 Subject: [PATCH 28/67] Topic: get shouldn't require capabilities, since it's a read-only operation. Signed-off-by: James McCoy --- plugins/Topic/plugin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/Topic/plugin.py b/plugins/Topic/plugin.py index f1b109d57..bd70c5ef8 100644 --- a/plugins/Topic/plugin.py +++ b/plugins/Topic/plugin.py @@ -392,9 +392,6 @@ class Topic(callbacks.Plugin): index into the topics. is only necessary if the message isn't sent in the channel itself. """ - if not self._checkManageCapabilities(irc, msg, channel): - capabilities = self.registryValue('requireManageCapability') - irc.errorNoCapability(capabilities, Raise=True) topics = self._splitTopic(irc.state.getTopic(channel), channel) irc.reply(topics[number]) get = wrap(get, ['inChannel', 'topicNumber']) From 8ded033410579c80bf444f5aaca5b3f3180a7404 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 3 Apr 2011 14:45:33 +0200 Subject: [PATCH 29/67] Seen: Fix save Seen.any.db. (thanks to beo_ for the repport and the test) Signed-off-by: Daniel Folkinshteyn Signed-off-by: James McCoy --- plugins/Seen/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 8ea6ea9fd..02f897ecd 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -99,6 +99,7 @@ class Seen(callbacks.Plugin): self.lastmsg = {} self.ircstates = {} world.flushers.append(self.db.flush) + world.flushers.append(self.anydb.flush) def die(self): if self.db.flush in world.flushers: From ce9891368b15f927689f366c113a9cf9747bc048 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 4 Apr 2011 16:30:52 -0400 Subject: [PATCH 30/67] Seen: fix tests so they pass. fix seen command so it properly accepts nick wildcards. Signed-off-by: James McCoy --- plugins/Seen/plugin.py | 4 ++-- plugins/Seen/test.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/Seen/plugin.py b/plugins/Seen/plugin.py index 02f897ecd..d330d45c4 100644 --- a/plugins/Seen/plugin.py +++ b/plugins/Seen/plugin.py @@ -216,10 +216,10 @@ class Seen(callbacks.Plugin): Returns the last time was seen and what was last seen saying. is only necessary if the message isn't sent on the - channel itself. + channel itself. may contain * as a wildcard. """ self._seen(irc, channel, name) - seen = wrap(seen, ['channel', 'nick']) + seen = wrap(seen, ['channel', 'something']) def any(self, irc, msg, args, channel, optlist, name): """[] [--user ] [] diff --git a/plugins/Seen/test.py b/plugins/Seen/test.py index 0ae819b6b..77eaeb37d 100644 --- a/plugins/Seen/test.py +++ b/plugins/Seen/test.py @@ -50,7 +50,7 @@ class ChannelDBTestCase(ChannelPluginTestCase): self.assertNotRegexp('seen asldfkjasdlfkj', 'KeyError') def testAny(self): - self.assertRegexp('seen any', 'test has joined') + self.assertRegexp('seen any', 'test has joined') self.irc.feedMsg(ircmsgs.mode(self.channel, args=('+o', self.nick), prefix=self.prefix)) self.assertRegexp('seen any %s' % self.nick, From f31035033593509c8d794379baf96c8e23316d42 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 13 Jun 2011 16:42:57 -0400 Subject: [PATCH 31/67] Web: add 'timeout' config for web fetch, default 5 sec. Otherwise, when a site would take a long time to respond, the thread would hang for quite a while. also needed to mod src/utils/web.py to take the timeout arg. Signed-off-by: James McCoy --- plugins/Web/config.py | 5 +++++ plugins/Web/plugin.py | 5 ++++- src/utils/web.py | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/Web/config.py b/plugins/Web/config.py index 2ce3e3bfc..f22e0ad15 100644 --- a/plugins/Web/config.py +++ b/plugins/Web/config.py @@ -58,4 +58,9 @@ conf.registerGlobalValue(Web.fetch, 'maximum', registry.NonNegativeInteger(0, """Determines the maximum number of bytes the bot will download via the 'fetch' command in this plugin.""")) +conf.registerGlobalValue(Web.fetch, 'timeout', + registry.NonNegativeInteger(5, """Determines the maximum number of + seconds the bot will wait for the site to respond, when using the 'fetch' + command in this plugin. If 0, will use socket.defaulttimeout""")) + # vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index 78ba0f0d3..00cd104e7 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -236,7 +236,10 @@ class Web(callbacks.PluginRegexp): irc.error('This command is disabled ' '(supybot.plugins.Web.fetch.maximum is set to 0).', Raise=True) - fd = utils.web.getUrlFd(url) + timeout = self.registryValue('fetch.timeout') + if timeout == 0: + timeout = None + fd = utils.web.getUrlFd(url, timeout=timeout) irc.reply(fd.read(max)) fetch = wrap(fetch, ['url']) diff --git a/src/utils/web.py b/src/utils/web.py index cbd0ecc73..f460bb1f6 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -96,7 +96,7 @@ defaultHeaders = { # application-specific function. Feel free to use a callable here. proxy = None -def getUrlFd(url, headers=None, data=None): +def getUrlFd(url, headers=None, data=None, timeout=None): """getUrlFd(url, headers=None, data=None) Opens the given url and returns a file object. Headers and data are @@ -114,7 +114,7 @@ def getUrlFd(url, headers=None, data=None): httpProxy = force(proxy) if httpProxy: request.set_proxy(httpProxy, 'http') - fd = urllib2.urlopen(request) + fd = urllib2.urlopen(request, timeout=timeout) return fd except socket.timeout, e: raise Error, TIMED_OUT From 6a9af819a3d3637ef81c0a564fdfec5b6e27d5fd Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:28:50 -0400 Subject: [PATCH 32/67] src/commands.py: make subprocesses raise an error on timeout, rather than return a string Signed-off-by: James McCoy --- src/commands.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index 49d682437..983176751 100644 --- a/src/commands.py +++ b/src/commands.py @@ -61,6 +61,10 @@ def thread(f): f(self, irc, msg, args, *L, **kwargs) return utils.python.changeFunctionName(newf, f.func_name, f.__doc__) +class ProcessTimeoutError(Exception): + """Gets raised when a process is killed due to timeout.""" + pass + def process(f, *args, **kwargs): """Runs a function in a subprocess. @@ -85,7 +89,7 @@ def process(f, *args, **kwargs): p.join(timeout) if p.is_alive(): p.terminate() - return "%s aborted due to timeout." % (p.name,) + raise ProcessTimeoutError, "%s aborted due to timeout." % (p.name,) try: v = q.get(block=False) except Queue.Empty: From 72c5c8ec09d92fe2270a1ee4edcb2f5a24dd0d1c Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:30:09 -0400 Subject: [PATCH 33/67] String: fix it up to work with the previously committed enhancement for subprocess timeout. Signed-off-by: James McCoy --- plugins/String/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index 0aa8bc338..ea734d9e7 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -142,8 +142,11 @@ class String(callbacks.Plugin): irc.error(s) else: t = self.registryValue('re.timeout') - v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') - irc.reply(v) + try: + v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') + irc.reply(v) + except commands.ProcessTimeoutError, e: + irc.error("ProcessTimeoutError: %s" % (e,)) re = thread(wrap(re, [first('regexpMatcher', 'regexpReplacer'), 'text'])) From 9356d0734fd826cde0fad256279ce4a3d3a9f950 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:30:46 -0400 Subject: [PATCH 34/67] Misc: fix potential ddos when misc.last command is fed a specially-crafted regexp. Signed-off-by: James McCoy --- plugins/Misc/plugin.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 1fbb94a2d..9c51b6500 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -35,6 +35,7 @@ import time import supybot import supybot.conf as conf +from supybot import commands import supybot.utils as utils from supybot.commands import * import supybot.ircdb as ircdb @@ -312,10 +313,25 @@ class Misc(callbacks.Plugin): predicates.setdefault('without', []).append(f) elif option == 'regexp': def f(m, arg=arg): + def f1(s, arg): + """Since we can't enqueue match objects into the multiprocessing queue, + we'll just wrap the function to return bools.""" + if arg.search(s) is not None: + return True + else: + return False if ircmsgs.isAction(m): - return arg.search(ircmsgs.unAction(m)) + m1 = ircmsgs.unAction(m) else: - return arg.search(m.args[1]) + m1 = m.args[1] + try: + # use a subprocess here, since specially crafted regexps can + # take exponential time and hang up the bot. + # timeout of 0.1 should be more than enough for any normal regexp. + v = commands.process(f1, m1, arg, timeout=0.1, pn=self.name(), cn='last') + return v + except commands.ProcessTimeoutError: + return False predicates.setdefault('regexp', []).append(f) elif option == 'nolimit': nolimit = True From ac500b059ac02ff5a104a25d2f5ff9f4848f536d Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 16:38:36 -0400 Subject: [PATCH 35/67] String: set default re subprocess timeout to 0.1, since that should be quite enough. Signed-off-by: James McCoy --- plugins/String/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/String/config.py b/plugins/String/config.py index 3992d9631..6ed4e2c2a 100644 --- a/plugins/String/config.py +++ b/plugins/String/config.py @@ -52,7 +52,7 @@ conf.registerGlobalValue(String.levenshtein, 'max', command.""")) conf.registerGroup(String, 're') conf.registerGlobalValue(String.re, 'timeout', - registry.PositiveFloat(5, """Determines the maximum time, in seconds, that + registry.PositiveFloat(0.1, """Determines the maximum time, in seconds, that a regular expression is given to execute before being terminated. Since there is a possibility that user input for the re command can cause it to eat up large amounts of ram or cpu time, it's a good idea to keep this From 18ec61842cd4014cb09401787b113f5293a81852 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Fri, 12 Aug 2011 18:10:41 -0400 Subject: [PATCH 36/67] Secure some more commands which take a regexp from untrusted user input. Namely todo.search, note.search, dunno.search. Signed-off-by: James McCoy --- plugins/Note/plugin.py | 4 +++- plugins/Todo/plugin.py | 3 +++ plugins/__init__.py | 3 ++- src/commands.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/plugins/Note/plugin.py b/plugins/Note/plugin.py index e624e1a3e..e751b2318 100644 --- a/plugins/Note/plugin.py +++ b/plugins/Note/plugin.py @@ -35,6 +35,7 @@ import operator import supybot.dbi as dbi import supybot.log as log import supybot.conf as conf +from supybot import commands import supybot.utils as utils import supybot.ircdb as ircdb from supybot.commands import * @@ -292,7 +293,8 @@ class Note(callbacks.Plugin): own = to for (option, arg) in optlist: if option == 'regexp': - criteria.append(arg.search) + criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, + timeout=0.1, plugin_name=self.name(), fcn_name='search')) elif option == 'sent': own = frm if glob: diff --git a/plugins/Todo/plugin.py b/plugins/Todo/plugin.py index d30e37feb..b0038a7d6 100644 --- a/plugins/Todo/plugin.py +++ b/plugins/Todo/plugin.py @@ -35,6 +35,7 @@ import operator import supybot.dbi as dbi import supybot.conf as conf +from supybot import commands import supybot.ircdb as ircdb import supybot.utils as utils from supybot.commands import * @@ -228,6 +229,8 @@ class Todo(callbacks.Plugin): criteria = [] for (option, arg) in optlist: if option == 'regexp': + criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, + timeout=0.1, plugin_name=self.name(), fcn_name='search')) criteria.append(arg.search) for glob in globs: glob = utils.python.glob2re(glob) diff --git a/plugins/__init__.py b/plugins/__init__.py index eeba620be..3ec8ce00b 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -419,7 +419,8 @@ class ChannelIdDatabasePlugin(callbacks.Plugin): if opt == 'by': predicates.append(lambda r, arg=arg: r.by == arg.id) elif opt == 'regexp': - predicates.append(lambda r, arg=arg: arg.search(r.text)) + predicates.append(lambda x: regexp_wrapper(x.text, reobj=arg, + timeout=0.1, plugin_name=self.name(), fcn_name='search')) if glob: def globP(r, glob=glob.lower()): return fnmatch.fnmatch(r.text.lower(), glob) diff --git a/src/commands.py b/src/commands.py index 983176751..ba0c3033b 100644 --- a/src/commands.py +++ b/src/commands.py @@ -98,6 +98,24 @@ def process(f, *args, **kwargs): v = "Error: " + str(v) return v +def regexp_wrapper(s, reobj, timeout, plugin_name, fcn_name): + '''A convenient wrapper to stuff regexp search queries through a subprocess. + + This is used because specially-crafted regexps can use exponential time + and hang the bot.''' + def re_bool(s, reobj): + """Since we can't enqueue match objects into the multiprocessing queue, + we'll just wrap the function to return bools.""" + if reobj.search(s) is not None: + return True + else: + return False + try: + v = process(re_bool, s, reobj, timeout=timeout, pn=plugin_name, cn=fcn_name) + return v + except ProcessTimeoutError: + return False + class UrlSnarfThread(world.SupyThread): def __init__(self, *args, **kwargs): assert 'url' in kwargs From dfdfd00b042c7913ee6031a534444844b9b88219 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 21 Nov 2011 15:09:38 -0500 Subject: [PATCH 37/67] core: make sure owner is never ignored. also simplify the logic flow in ignore checking. Thanks m4v for the patch! Signed-off-by: James McCoy --- src/ircdb.py | 52 ++++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/src/ircdb.py b/src/ircdb.py index 403a09df2..f01e2e5b6 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -937,46 +937,30 @@ def checkIgnored(hostmask, recipient='', users=users, channels=channels): Checks if the user is ignored by the recipient of the message. """ - if ignores.checkIgnored(hostmask): - log.debug('Ignoring %s due to ignore database.', hostmask) - return True try: id = users.getUserId(hostmask) user = users.getUser(id) + if user._checkCapability('owner'): + # Owners shouldn't ever be ignored. + return False + elif user.ignore: + log.debug('Ignoring %s due to his IrcUser ignore flag.', hostmask) + return True except KeyError: # If there's no user... - if ircutils.isChannel(recipient): - channel = channels.getChannel(recipient) - if channel.checkIgnored(hostmask): - log.debug('Ignoring %s due to the channel ignores.', hostmask) - return True - else: - return False - else: - if conf.supybot.defaultIgnore(): - log.debug('Ignoring %s due to conf.supybot.defaultIgnore', - hostmask) - return True - else: - return False - if user._checkCapability('owner'): - # Owners shouldn't ever be ignored. - return False - elif user.ignore: - log.debug('Ignoring %s due to his IrcUser ignore flag.', hostmask) + if conf.supybot.defaultIgnore(): + log.debug('Ignoring %s due to conf.supybot.defaultIgnore', + hostmask) + return True + if ignores.checkIgnored(hostmask): + log.debug('Ignoring %s due to ignore database.', hostmask) return True - elif recipient: - if ircutils.isChannel(recipient): - channel = channels.getChannel(recipient) - if channel.checkIgnored(hostmask): - log.debug('Ignoring %s due to the channel ignores.', hostmask) - return True - else: - return False - else: - return False - else: - return False + if ircutils.isChannel(recipient): + channel = channels.getChannel(recipient) + if channel.checkIgnored(hostmask): + log.debug('Ignoring %s due to the channel ignores.', hostmask) + return True + return False def _x(capability, ret): if isAntiCapability(capability): From 7d87d422e1f5a4bba7193ad888fb41cd57f6d28d Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 2 Jan 2011 13:22:54 +0100 Subject: [PATCH 38/67] Fix detection of .42 domains Signed-off-by: James McCoy --- src/utils/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/web.py b/src/utils/web.py index f460bb1f6..b0cf22759 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -57,7 +57,7 @@ _octet = r'(?:2(?:[0-4]\d|5[0-5])|1\d\d|\d{1,2})' _ipAddr = r'%s(?:\.%s){3}' % (_octet, _octet) # Base domain regex off RFC 1034 and 1738 _label = r'[0-9a-z][-0-9a-z]*[0-9a-z]?' -_domain = r'%s(?:\.%s)*\.[a-z][-0-9a-z]*[a-z]?' % (_label, _label) +_domain = r'%s(?:\.%s)*\.[0-9a-z][-0-9a-z]+' % (_label, _label) _urlRe = r'(\w+://(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % (_domain, _ipAddr) urlRe = re.compile(_urlRe, re.I) _httpUrlRe = r'(https?://(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % (_domain, From db3746d122fb9b435d423fb6a476eb53745b5be2 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 20 Oct 2012 20:23:32 +0200 Subject: [PATCH 39/67] Add support for authentication scheme. This commit closes http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=690879 Signed-off-by: James McCoy --- src/utils/web.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/utils/web.py b/src/utils/web.py index b0cf22759..fe0e939de 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -29,6 +29,7 @@ ### import re +import base64 import socket import urllib import urllib2 @@ -58,10 +59,11 @@ _ipAddr = r'%s(?:\.%s){3}' % (_octet, _octet) # Base domain regex off RFC 1034 and 1738 _label = r'[0-9a-z][-0-9a-z]*[0-9a-z]?' _domain = r'%s(?:\.%s)*\.[0-9a-z][-0-9a-z]+' % (_label, _label) -_urlRe = r'(\w+://(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % (_domain, _ipAddr) +_urlRe = r'(\w+://(?:\S+@)?(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % (_domain, + _ipAddr) urlRe = re.compile(_urlRe, re.I) -_httpUrlRe = r'(https?://(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % (_domain, - _ipAddr) +_httpUrlRe = r'(https?://(?:\S+@)?(?:%s|%s)(?::\d+)?(?:/[^\])>\s]*)?)' % \ + (_domain, _ipAddr) httpUrlRe = re.compile(_httpUrlRe, re.I) REFUSED = 'Connection refused.' @@ -108,6 +110,12 @@ def getUrlFd(url, headers=None, data=None, timeout=None): if '#' in url: url = url[:url.index('#')] request = urllib2.Request(url, headers=headers, data=data) + if '@' in url: + scheme, url = url.split('://', 2) + auth, url = url.split('@') + url = scheme + '://' + url + request.add_header('Authorization', + 'Basic ' + base64.b64encode(auth)) else: request = url request.add_data(data) From 6ab807be434950b6bc925ee94309135f6474ce58 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 20 Oct 2012 19:43:11 -0400 Subject: [PATCH 40/67] utils.net: Use _ipAddr and _domain from utils.web to define emailRe Signed-off-by: James McCoy --- src/utils/net.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/utils/net.py b/src/utils/net.py index 470949f25..dd5148378 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -35,9 +35,10 @@ Simple utility modules. import re import socket -emailRe = re.compile(r"^(\w&.+-]+!)*[\w&.+-]+@" - r"(([0-9a-z]([0-9a-z-]*[0-9a-z])?\.)[a-z]{2,6}|" - r"([0-9]{1,3}\.){3}[0-9]{1,3})$", re.I) +from .web import _ipAddr, _domain + +emailRe = re.compile(r"^(\w&.+-]+!)*[\w&.+-]+@(%s|%s)$" % (_domain, _ipAddr), + re.I) def getSocket(host): """Returns a socket of the correct AF_INET type (v4 or v6) in order to From 180508496faba128297e0fe70c2f530413779450 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 20 Oct 2012 19:43:55 -0400 Subject: [PATCH 41/67] docs/USING_WRAP.rst: Add docs for missing converters Signed-off-by: James McCoy --- docs/USING_WRAP.rst | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/USING_WRAP.rst b/docs/USING_WRAP.rst index 61fa66fbd..3a090e065 100644 --- a/docs/USING_WRAP.rst +++ b/docs/USING_WRAP.rst @@ -203,6 +203,10 @@ optional, the default value is shown. - Checks for a valid HTTP URL. +* email + + - Checks for a syntactically valid email address. + * long, type="long" - Basically the same as int minus the predicate, except that it converts the @@ -417,10 +421,23 @@ optional, the default value is shown. - Checks to make sure that the caller has the specified capability. -"checkChannelCapability", capability - Checks to make sure that the caller has the specified capability on the +* checkChannelCapability, capability + + - Checks to make sure that the caller has the specified capability on the channel the command is called in. +* op + + - Checks whether the user has the op mode (+o) set. + +* halfop + + - Checks whether the user has the halfop mode (+h) set. + +* voice + + - Checks whether the user has the voice mode (+v) set. + Contexts List ============= What contexts are available for me to use? From 90b7f3cd4e41e475d300d4c95c16a7be0911e2c4 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Tue, 23 Oct 2012 17:06:30 -0400 Subject: [PATCH 42/67] utils.web: Simplify getUrlFd by using standard parse methods Signed-off-by: James McCoy --- src/utils/web.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/utils/web.py b/src/utils/web.py index fe0e939de..163a937a4 100644 --- a/src/utils/web.py +++ b/src/utils/web.py @@ -107,15 +107,13 @@ def getUrlFd(url, headers=None, data=None, timeout=None): headers = defaultHeaders try: if not isinstance(url, urllib2.Request): - if '#' in url: - url = url[:url.index('#')] + (scheme, loc, path, query, frag) = urlparse.urlsplit(url) + (user, host) = urllib.splituser(loc) + url = urlparse.urlunsplit((scheme, host, path, query, '')) request = urllib2.Request(url, headers=headers, data=data) - if '@' in url: - scheme, url = url.split('://', 2) - auth, url = url.split('@') - url = scheme + '://' + url + if user: request.add_header('Authorization', - 'Basic ' + base64.b64encode(auth)) + 'Basic %s' % base64.b64encode(user)) else: request = url request.add_data(data) From c774be3ea6fb93f01d28c9f7f289d9151e9f3e3e Mon Sep 17 00:00:00 2001 From: James McCoy Date: Wed, 24 Oct 2012 00:23:25 -0400 Subject: [PATCH 43/67] commands: Add process and regexp_wrapper to __all__ Signed-off-by: James McCoy --- src/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index ba0c3033b..131f27729 100644 --- a/src/commands.py +++ b/src/commands.py @@ -985,7 +985,7 @@ __all__ = [ # Decorators. 'urlSnarfer', 'thread', # Functions. - 'wrap', + 'wrap', 'process', 'regexp_wrapper', # Stuff for testing. 'Spec', ] From 57e429011db5b4bc2e223f68ae9bf83e5535920c Mon Sep 17 00:00:00 2001 From: James McCoy Date: Wed, 24 Oct 2012 00:24:16 -0400 Subject: [PATCH 44/67] Misc: Use regexp_wrapper for Misc.last Signed-off-by: James McCoy --- plugins/Misc/plugin.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 9c51b6500..6d30e16fc 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -313,25 +313,13 @@ class Misc(callbacks.Plugin): predicates.setdefault('without', []).append(f) elif option == 'regexp': def f(m, arg=arg): - def f1(s, arg): - """Since we can't enqueue match objects into the multiprocessing queue, - we'll just wrap the function to return bools.""" - if arg.search(s) is not None: - return True - else: - return False if ircmsgs.isAction(m): m1 = ircmsgs.unAction(m) else: m1 = m.args[1] - try: - # use a subprocess here, since specially crafted regexps can - # take exponential time and hang up the bot. - # timeout of 0.1 should be more than enough for any normal regexp. - v = commands.process(f1, m1, arg, timeout=0.1, pn=self.name(), cn='last') - return v - except commands.ProcessTimeoutError: - return False + return regexp_wrapper(m1, reobj=arg, timeout=0.1, + plugin_name=self.name(), + fcn_name='last') predicates.setdefault('regexp', []).append(f) elif option == 'nolimit': nolimit = True From b5eac0994a870d065209c07119ebf00b4a3ae8b7 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Wed, 24 Oct 2012 00:26:51 -0400 Subject: [PATCH 45/67] Call unqualified process/regexp_wrapper, since commands exports them now. Signed-off-by: James McCoy --- plugins/Note/plugin.py | 7 ++++--- plugins/String/plugin.py | 2 +- plugins/Todo/plugin.py | 8 ++++---- plugins/__init__.py | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/Note/plugin.py b/plugins/Note/plugin.py index e751b2318..3e8929ea7 100644 --- a/plugins/Note/plugin.py +++ b/plugins/Note/plugin.py @@ -35,7 +35,6 @@ import operator import supybot.dbi as dbi import supybot.log as log import supybot.conf as conf -from supybot import commands import supybot.utils as utils import supybot.ircdb as ircdb from supybot.commands import * @@ -293,8 +292,10 @@ class Note(callbacks.Plugin): own = to for (option, arg) in optlist: if option == 'regexp': - criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, - timeout=0.1, plugin_name=self.name(), fcn_name='search')) + criteria.append(lambda s: + regexp_wrapper(s, reobj=arg, timeout=0.1, + plugin_name=self.name(), + fcn_name='search')) elif option == 'sent': own = frm if glob: diff --git a/plugins/String/plugin.py b/plugins/String/plugin.py index ea734d9e7..46f77a643 100644 --- a/plugins/String/plugin.py +++ b/plugins/String/plugin.py @@ -143,7 +143,7 @@ class String(callbacks.Plugin): else: t = self.registryValue('re.timeout') try: - v = commands.process(f, text, timeout=t, pn=self.name(), cn='re') + v = process(f, text, timeout=t, pn=self.name(), cn='re') irc.reply(v) except commands.ProcessTimeoutError, e: irc.error("ProcessTimeoutError: %s" % (e,)) diff --git a/plugins/Todo/plugin.py b/plugins/Todo/plugin.py index b0038a7d6..03bc9ee9a 100644 --- a/plugins/Todo/plugin.py +++ b/plugins/Todo/plugin.py @@ -35,7 +35,6 @@ import operator import supybot.dbi as dbi import supybot.conf as conf -from supybot import commands import supybot.ircdb as ircdb import supybot.utils as utils from supybot.commands import * @@ -229,9 +228,10 @@ class Todo(callbacks.Plugin): criteria = [] for (option, arg) in optlist: if option == 'regexp': - criteria.append(lambda x: commands.regexp_wrapper(x, reobj=arg, - timeout=0.1, plugin_name=self.name(), fcn_name='search')) - criteria.append(arg.search) + criteria.append(lambda s: + regexp_wrapper(s, reobj=arg, timeout=0.1, + plugin_name=self.name(), + fcn_name='search')) for glob in globs: glob = utils.python.glob2re(glob) criteria.append(re.compile(glob).search) diff --git a/plugins/__init__.py b/plugins/__init__.py index 3ec8ce00b..008acda48 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -419,7 +419,7 @@ class ChannelIdDatabasePlugin(callbacks.Plugin): if opt == 'by': predicates.append(lambda r, arg=arg: r.by == arg.id) elif opt == 'regexp': - predicates.append(lambda x: regexp_wrapper(x.text, reobj=arg, + predicates.append(lambda r: regexp_wrapper(r.text, reobj=arg, timeout=0.1, plugin_name=self.name(), fcn_name='search')) if glob: def globP(r, glob=glob.lower()): From 8062d9592ccf28ea0a3e7415be7d6847897dc458 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 4 Apr 2012 15:08:49 +0200 Subject: [PATCH 46/67] Change the minimal number of non-wildcard characters in hostmask from 8 to 3. Closes GH-276. Signed-off-by: James McCoy --- src/ircdb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ircdb.py b/src/ircdb.py index f01e2e5b6..5f10cc107 100644 --- a/src/ircdb.py +++ b/src/ircdb.py @@ -281,9 +281,9 @@ class IrcUser(object): def addHostmask(self, hostmask): """Adds a hostmask to the user's hostmasks.""" assert ircutils.isUserHostmask(hostmask), 'got %s' % hostmask - if len(unWildcardHostmask(hostmask)) < 8: + if len(unWildcardHostmask(hostmask)) < 3: raise ValueError, \ - 'Hostmask must contain at least 8 non-wildcard characters.' + 'Hostmask must contain at least 3 non-wildcard characters.' self.hostmasks.add(hostmask) def removeHostmask(self, hostmask): From 9b8397193b6b86fb835e481fdbb1ee8695dd2670 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 30 Oct 2011 14:21:53 +0100 Subject: [PATCH 47/67] Admin: Add clearq command. Signed-off-by: James McCoy --- plugins/Admin/plugin.py | 9 +++++++++ sandbox/Debug/plugin.py | 8 -------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/Admin/plugin.py b/plugins/Admin/plugin.py index 3df4279a0..4f5abb728 100644 --- a/plugins/Admin/plugin.py +++ b/plugins/Admin/plugin.py @@ -332,6 +332,15 @@ class Admin(callbacks.Plugin): irc.reply('I\'m not currently globally ignoring anyone.') list = wrap(list) + def clearq(self, irc, msg, args): + """takes no arguments + + Clears the current send queue for this network. + """ + irc.queue.reset() + irc.replySuccess() + clearq = wrap(clearq) + Class = Admin diff --git a/sandbox/Debug/plugin.py b/sandbox/Debug/plugin.py index 3ee72750f..bfe2b7f8f 100644 --- a/sandbox/Debug/plugin.py +++ b/sandbox/Debug/plugin.py @@ -198,14 +198,6 @@ class Debug(callbacks.Privmsg): irc.reply(repr(os.environ)) environ = wrap(environ) - def clearq(self, irc, msg, args): - """takes no arguments - - Clears the current send queue for this network. - """ - irc.queue.reset() - irc.replySuccess() - Class = Debug From 484d7e6facabf0a4faed9e210851609b26ef63e3 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sun, 7 Aug 2011 11:30:51 +0200 Subject: [PATCH 48/67] Misc: Notify the caller when @tell succeeded. Closes GH-97. Signed-off-by: James McCoy --- plugins/Misc/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/Misc/plugin.py b/plugins/Misc/plugin.py index 6d30e16fc..c53c71bce 100644 --- a/plugins/Misc/plugin.py +++ b/plugins/Misc/plugin.py @@ -406,6 +406,7 @@ class Misc(callbacks.Plugin): irc.action = False text = '* %s %s' % (irc.nick, text) s = '%s wants me to tell you: %s' % (msg.nick, text) + irc.replySuccess() irc.reply(s, to=target, private=True) tell = wrap(tell, ['something', 'text']) From c68afacc0f87a801f27a9f86f79bad7ce216b1b5 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Mon, 22 Oct 2012 11:24:28 -0400 Subject: [PATCH 49/67] Math: calc: coerce argument to ascii string. working with unicode errors on the translate() step. Signed-off-by: James McCoy --- plugins/Math/plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index 98bda7726..dbea4303c 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -160,6 +160,13 @@ class Math(callbacks.Plugin): crash to the bot with something like '10**10**10**10'. One consequence is that large values such as '10**24' might not be exact. """ + try: + text = str(text) + except UnicodeEncodeError: + irc.error("There's no reason you should have fancy non-ASCII " + "characters in your mathematical expression. " + "Please remove them.") + return if text != text.translate(utils.str.chars, '_[]'): irc.error('There\'s really no reason why you should have ' 'underscores or brackets in your mathematical ' From ac13d09511caef4404f23170bbca2d4a7d120f79 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 27 Oct 2012 17:59:05 +0200 Subject: [PATCH 50/67] conf.py: Prevent traceback if server address is an IPv6 address. Signed-off-by: James McCoy --- src/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/conf.py b/src/conf.py index b8506e5df..f23506b63 100644 --- a/src/conf.py +++ b/src/conf.py @@ -223,7 +223,7 @@ class Servers(registry.SpaceSeparatedListOfStrings): def convert(self, s): s = self.normalize(s) - (server, port) = s.split(':') + (server, port) = s.rsplit(':', 2) port = int(port) return (server, port) From 910ad6dd62a0d154f69c1b161d04a18c71f86f51 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Thu, 30 Aug 2012 15:58:51 -0400 Subject: [PATCH 51/67] core: make network.channels and channel keys private by default. Otherwise these can reveal secret information. Signed-off-by: James McCoy --- src/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/conf.py b/src/conf.py index f23506b63..b3dba0d8f 100644 --- a/src/conf.py +++ b/src/conf.py @@ -262,12 +262,12 @@ def registerNetwork(name, password='', ssl=False): is completed.""" % name)) registerGlobalValue(network, 'channels', SpaceSeparatedSetOfChannels([], """Space-separated list of channels the bot will join only on %s.""" - % name)) + % name, private=True)) registerGlobalValue(network, 'ssl', registry.Boolean(ssl, """Determines whether the bot will attempt to connect with SSL sockets to %s.""" % name)) registerChannelValue(network.channels, 'key', registry.String('', - """Determines what key (if any) will be used to join the channel.""")) + """Determines what key (if any) will be used to join the channel.""", private=True)) return network # Let's fill our networks. From 3c30463e1139da429176c6e8f7541cbc44843ad3 Mon Sep 17 00:00:00 2001 From: Daniel Folkinshteyn Date: Tue, 12 Jun 2012 12:28:26 -0400 Subject: [PATCH 52/67] Math: default %f formatting rounds to 6 decimal places. increase that to 16. Signed-off-by: James McCoy --- plugins/Math/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Math/plugin.py b/plugins/Math/plugin.py index dbea4303c..98a4d4722 100644 --- a/plugins/Math/plugin.py +++ b/plugins/Math/plugin.py @@ -195,7 +195,7 @@ class Math(callbacks.Plugin): # use of str() on large numbers loses information: # str(float(33333333333333)) => '3.33333333333e+13' # float('3.33333333333e+13') => 33333333333300.0 - return '%f' % x + return '%.16f' % x return str(x) text = self._mathRe.sub(handleMatch, text) try: From 8d8e574d125ab066a4f4798dddca5b13989b0273 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 8 Mar 2011 15:18:38 +0100 Subject: [PATCH 53/67] Owner: fix error message when the ImportError comes from the plugin --- plugins/Owner/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Owner/plugin.py b/plugins/Owner/plugin.py index ba0696261..abde22bea 100644 --- a/plugins/Owner/plugin.py +++ b/plugins/Owner/plugin.py @@ -432,7 +432,7 @@ class Owner(callbacks.Plugin): 'to force it to load.' % name.capitalize()) return except ImportError, e: - if name in str(e): + if str(e).endswith(' ' + name): irc.error('No plugin named %s exists.' % utils.str.dqrepr(name)) else: irc.error(str(e)) From 295f9b1f0d261a0286acbb14cd0c4e59abcedbec Mon Sep 17 00:00:00 2001 From: James McCoy Date: Mon, 31 Dec 2012 17:29:02 -0500 Subject: [PATCH 54/67] Make utils.str.soundex perform better when length is large Closes: Sf patch#148 Signed-off-by: James McCoy --- src/utils/str.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/str.py b/src/utils/str.py index 02ca65a6c..43abd608d 100644 --- a/src/utils/str.py +++ b/src/utils/str.py @@ -86,7 +86,10 @@ _soundextrans = string.maketrans(string.ascii_uppercase, '01230120022455012623010202') _notUpper = chars.translate(chars, string.ascii_uppercase) def soundex(s, length=4): - """Returns the soundex hash of a given string.""" + """Returns the soundex hash of a given string. + + length=0 doesn't truncate the hash. + """ s = s.upper() # Make everything uppercase. s = s.translate(chars, _notUpper) # Delete non-letters. if not s: @@ -98,9 +101,11 @@ def soundex(s, length=4): for c in s: if c != L[-1]: L.append(c) - L = [c for c in L if c != '0'] + (['0']*(length-1)) + L = [c for c in L if c != '0'] s = ''.join(L) - return length and s[:length] or s.rstrip('0') + if length: + s = s.ljust(length, '0')[:length] + return s def dqrepr(s): """Returns a repr() of s guaranteed to be in double quotes.""" From 2327317b33215ff1767ff0768bca84e15c3c5174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Terje=20Ho=C3=A5s?= Date: Tue, 2 Oct 2012 18:19:53 +0200 Subject: [PATCH 55/67] Web: Fix fetch. Use getUrl instead of getUrlFd. Signed-off-by: James McCoy --- plugins/Web/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/Web/plugin.py b/plugins/Web/plugin.py index 00cd104e7..0d907ac3b 100644 --- a/plugins/Web/plugin.py +++ b/plugins/Web/plugin.py @@ -239,8 +239,8 @@ class Web(callbacks.PluginRegexp): timeout = self.registryValue('fetch.timeout') if timeout == 0: timeout = None - fd = utils.web.getUrlFd(url, timeout=timeout) - irc.reply(fd.read(max)) + s = utils.web.getUrl(url, timeout=timeout, size=max) + irc.reply(s) fetch = wrap(fetch, ['url']) Class = Web From a8e3081b180ab204e70ec9c08f900b8a6437b0ac Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 28 May 2012 19:58:15 +0200 Subject: [PATCH 56/67] ShrinkUrl: Support for goo.gl url shortener. Signed-off-by: James McCoy --- plugins/ShrinkUrl/config.py | 6 +++--- plugins/ShrinkUrl/plugin.py | 32 ++++++++++++++++++++++++++++++++ plugins/ShrinkUrl/test.py | 5 +++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/plugins/ShrinkUrl/config.py b/plugins/ShrinkUrl/config.py index 0d496da3a..c8ed07267 100644 --- a/plugins/ShrinkUrl/config.py +++ b/plugins/ShrinkUrl/config.py @@ -40,11 +40,11 @@ def configure(advanced): conf.supybot.plugins.ShrinkUrl.shrinkSnarfer.setValue(True) class ShrinkService(registry.OnlySomeStrings): - """Valid values include 'ln', 'tiny', 'xrl', and 'x0'.""" - validStrings = ('ln', 'tiny', 'xrl', 'x0') + """Valid values include 'ln', 'tiny', 'xrl', 'goo', and 'x0'.""" + validStrings = ('ln', 'tiny', 'xrl', 'goo', 'x0') class ShrinkCycle(registry.SpaceSeparatedListOfStrings): - """Valid values include 'ln', 'tiny', 'xrl', and 'x0'.""" + """Valid values include 'ln', 'tiny', 'xrl', 'goo', and 'x0'.""" Value = ShrinkService def __init__(self, *args, **kwargs): diff --git a/plugins/ShrinkUrl/plugin.py b/plugins/ShrinkUrl/plugin.py index 063e751ec..2e8d464f3 100644 --- a/plugins/ShrinkUrl/plugin.py +++ b/plugins/ShrinkUrl/plugin.py @@ -29,6 +29,7 @@ ### import re +import json import supybot.conf as conf import supybot.utils as utils @@ -227,6 +228,37 @@ class ShrinkUrl(callbacks.PluginRegexp): irc.error(str(e)) xrl = thread(wrap(xrl, ['url'])) + _gooApi = 'https://www.googleapis.com/urlshortener/v1/url' + def _getGooUrl(self, url): + url = utils.web.urlquote(url) + try: + return self.db.get('goo', url) + except KeyError: + headers = utils.web.defaultHeaders.copy() + headers['content-type'] = 'application/json' + data = json.dumps({'longUrl': url}) + text = utils.web.getUrl(self._gooApi, data=data, headers=headers) + googl = json.loads(text)['id'] + if googl: + self.db.set('goo', url, googl) + return googl + else: + raise ShrinkError, text + + def goo(self, irc, msg, args, url): + """ + + Returns an goo.gl version of . + """ + try: + goourl = self._getGooUrl(url) + m = irc.reply(goourl) + if m is not None: + m.tag('shrunken') + except ShrinkError, e: + irc.error(str(e)) + goo = thread(wrap(goo, ['url'])) + _x0Api = 'http://api.x0.no/?%s' def _getX0Url(self, url): try: diff --git a/plugins/ShrinkUrl/test.py b/plugins/ShrinkUrl/test.py index 3a0855959..ffe18f2df 100644 --- a/plugins/ShrinkUrl/test.py +++ b/plugins/ShrinkUrl/test.py @@ -43,6 +43,8 @@ class ShrinkUrlTestCase(ChannelPluginTestCase): (udUrl, r'http://ln-s.net/2\$K')], 'xrl': [(sfUrl, r'http://xrl.us/bfq8ik'), (udUrl, r'http://xrl.us/bfnyji')], + 'goo': [(sfUrl, r'http://goo.gl/3c59N'), + (udUrl, r'http://goo.gl/ocTga')], 'x0': [(sfUrl, r'http://x0.no/0l2j'), (udUrl, r'http://x0.no/0l2k')] } @@ -97,6 +99,9 @@ class ShrinkUrlTestCase(ChannelPluginTestCase): def testXrlsnarf(self): self._snarf('xrl') + def testGoosnarf(self): + self._snarf('goo') + def testX0snarf(self): self._snarf('x0') From caa36121a7c04ab5fb63cd1ebe079d7a1c34e935 Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Sat, 11 Aug 2012 11:07:40 +0200 Subject: [PATCH 57/67] ShrinkUrl: Add ur1.ca support. Signed-off-by: James McCoy --- plugins/ShrinkUrl/config.py | 6 +++--- plugins/ShrinkUrl/plugin.py | 29 +++++++++++++++++++++++++++++ plugins/ShrinkUrl/test.py | 5 +++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/plugins/ShrinkUrl/config.py b/plugins/ShrinkUrl/config.py index c8ed07267..bb9c7338d 100644 --- a/plugins/ShrinkUrl/config.py +++ b/plugins/ShrinkUrl/config.py @@ -40,11 +40,11 @@ def configure(advanced): conf.supybot.plugins.ShrinkUrl.shrinkSnarfer.setValue(True) class ShrinkService(registry.OnlySomeStrings): - """Valid values include 'ln', 'tiny', 'xrl', 'goo', and 'x0'.""" - validStrings = ('ln', 'tiny', 'xrl', 'goo', 'x0') + """Valid values include 'ln', 'tiny', 'xrl', 'goo', 'ur1', and 'x0'.""" + validStrings = ('ln', 'tiny', 'xrl', 'goo', 'ur1', 'x0') class ShrinkCycle(registry.SpaceSeparatedListOfStrings): - """Valid values include 'ln', 'tiny', 'xrl', 'goo', and 'x0'.""" + """Valid values include 'ln', 'tiny', 'xrl', 'goo', 'ur1', and 'x0'.""" Value = ShrinkService def __init__(self, *args, **kwargs): diff --git a/plugins/ShrinkUrl/plugin.py b/plugins/ShrinkUrl/plugin.py index 2e8d464f3..c09bb3b80 100644 --- a/plugins/ShrinkUrl/plugin.py +++ b/plugins/ShrinkUrl/plugin.py @@ -259,6 +259,35 @@ class ShrinkUrl(callbacks.PluginRegexp): irc.error(str(e)) goo = thread(wrap(goo, ['url'])) + _ur1Api = 'http://ur1.ca/' + _ur1Regexp = re.compile(r'') + def _getUr1Url(self, url): + try: + return self.db.get('ur1ca', utils.web.urlquote(url)) + except KeyError: + parameters = utils.web.urlencode({'longurl': url}) + response = utils.web.getUrl(self._ur1Api, data=parameters) + ur1ca = self._ur1Regexp.search(response.decode()).group('url') + if ur1ca > 0 : + self.db.set('ur1', url, ur1ca) + return ur1ca + else: + raise ShrinkError, text + + def ur1(self, irc, msg, args, url): + """ + + Returns an ur1 version of . + """ + try: + ur1url = self._getUr1Url(url) + m = irc.reply(ur1url) + if m is not None: + m.tag('shrunken') + except ShrinkError, e: + irc.error(str(e)) + ur1 = thread(wrap(ur1, ['url'])) + _x0Api = 'http://api.x0.no/?%s' def _getX0Url(self, url): try: diff --git a/plugins/ShrinkUrl/test.py b/plugins/ShrinkUrl/test.py index ffe18f2df..1b3a7aa40 100644 --- a/plugins/ShrinkUrl/test.py +++ b/plugins/ShrinkUrl/test.py @@ -45,6 +45,8 @@ class ShrinkUrlTestCase(ChannelPluginTestCase): (udUrl, r'http://xrl.us/bfnyji')], 'goo': [(sfUrl, r'http://goo.gl/3c59N'), (udUrl, r'http://goo.gl/ocTga')], + 'ur1': [(sfUrl, r'http://ur1.ca/9xl25'), + (udUrl, r'http://ur1.ca/9xl9k')], 'x0': [(sfUrl, r'http://x0.no/0l2j'), (udUrl, r'http://x0.no/0l2k')] } @@ -102,6 +104,9 @@ class ShrinkUrlTestCase(ChannelPluginTestCase): def testGoosnarf(self): self._snarf('goo') + def testUr1snarf(self): + self._snarf('ur1') + def testX0snarf(self): self._snarf('x0') From 88e4f73777946c0f04af3c4a28c7a55f79f441d5 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Thu, 22 Aug 2013 23:40:28 -0400 Subject: [PATCH 58/67] getSocket: Use returned family to create the socket The existing code was parsing the passed in host to determine what type of socket family to create. getaddrinfo already provides this for us, so there's no need to perform our own, potentially buggy, parsing. Signed-off-by: James McCoy --- src/utils/net.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/utils/net.py b/src/utils/net.py index dd5148378..0c582b173 100644 --- a/src/utils/net.py +++ b/src/utils/net.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2005, Jeremiah Fincher -# Copyright (c) 2011, James McCoy +# Copyright (c) 2011, 2013, James McCoy # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -44,14 +44,10 @@ def getSocket(host): """Returns a socket of the correct AF_INET type (v4 or v6) in order to communicate with host. """ - addrinfo = socket.getaddrinfo(host, None) - host = addrinfo[0][4][0] - if isIPV4(host): - return socket.socket(socket.AF_INET, socket.SOCK_STREAM) - elif isIPV6(host): - return socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - else: - raise socket.error, 'Something wonky happened.' + addrinfo = socket.getaddrinfo(host, None, + socket.AF_UNSPEC, socket.SOCK_STREAM) + family = addrinfo[0][0] + return socket.socket(family, socket.SOCK_STREAM) def isIP(s): """Returns whether or not a given string is an IP address. From 5b329df6f08bdf0f4009c83e5372ef277aba6b12 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Thu, 22 Aug 2013 23:43:09 -0400 Subject: [PATCH 59/67] Socket: Try all resolved addresses before scheduling a reconnect Instead of assuming the first address returned from getaddrinfo can be reached, try each one in turn until a connection is established. Signed-off-by: James McCoy --- src/drivers/Socket.py | 86 +++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index 0382270d5..ad4514d11 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -1,6 +1,6 @@ ### # Copyright (c) 2002-2004, Jeremiah Fincher -# Copyright (c) 2010, James McCoy +# Copyright (c) 2010, 2013, James McCoy # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -35,6 +35,7 @@ Contains simple socket drivers. Asyncore bugged (haha, pun!) me. from __future__ import division import time +import errno import select import socket @@ -163,43 +164,56 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): else: drivers.log.debug('Not resetting %s.', self.irc) server = self._getNextServer() + host = server[0] + address = None drivers.log.connect(self.currentServer) - try: - self.conn = utils.net.getSocket(server[0]) - if self.networkGroup.get('ssl').value: - if ssl: - self.plainconn = self.conn - self.conn = ssl.wrap_socket(self.conn) - else: - drivers.log.error('ssl module not available, ' - 'cannot connect to SSL servers.') - return - vhost = conf.supybot.protocols.irc.vhost() - self.conn.bind((vhost, 0)) - except socket.error, e: - drivers.log.connectError(self.currentServer, e) + for addrinfo in socket.getaddrinfo(server[0], None, + socket.AF_UNSPEC, + socket.SOCK_STREAM): + address = addrinfo[4][0] + try: + self.conn = socket.socket(addrinfo[0], addrinfo[1]) + if self.networkGroup.get('ssl').value: + if ssl: + self.plainconn = self.conn + self.conn = ssl.wrap_socket(self.conn) + else: + drivers.log.error('ssl module not available, ' + 'cannot connect to SSL servers.') + return + vhost = conf.supybot.protocols.irc.vhost() + self.conn.bind((vhost, 0)) + except socket.error, e: + msg = host + if host != address: + msg = '%s (%s)' % (host, address) + drivers.log.connectError(msg, e) + continue + # We allow more time for the connect here, since it might take longer. + # At least 10 seconds. + self.conn.settimeout(max(10, conf.supybot.drivers.poll()*10)) + try: + self.conn.connect((addrinfo[4][0], server[1])) + self.conn.settimeout(conf.supybot.drivers.poll()) + self.connected = True + self.resetDelay() + break + except socket.error, e: + if e.args[0] == errno.EINPROGRESS: + now = time.time() + when = now + 60 + whenS = log.timestamp(when) + drivers.log.debug('Connection in progress, scheduling ' + 'connectedness check for %s', whenS) + self.writeCheckTime = when + break + msg = host + if host != address: + msg = '%s (%s)' % (host, address) + drivers.log.connectError(msg, e) + continue + if not self.connected: self.scheduleReconnect() - return - # We allow more time for the connect here, since it might take longer. - # At least 10 seconds. - self.conn.settimeout(max(10, conf.supybot.drivers.poll()*10)) - try: - self.conn.connect(server) - self.conn.settimeout(conf.supybot.drivers.poll()) - self.connected = True - self.resetDelay() - except socket.error, e: - if e.args[0] == 115: - now = time.time() - when = now + 60 - whenS = log.timestamp(when) - drivers.log.debug('Connection in progress, scheduling ' - 'connectedness check for %s', whenS) - self.writeCheckTime = when - else: - drivers.log.connectError(self.currentServer, e) - self.scheduleReconnect() - return def _checkAndWriteOrReconnect(self): self.writeCheckTime = None From 960e1da61cad113ccae392c0c6d1fc8e2f195e2c Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Tue, 16 Jul 2013 20:20:08 +0000 Subject: [PATCH 60/67] commands.py: Fix error message of getSomethingWithoutSpaces. Signed-off-by: James McCoy --- src/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index 131f27729..71d66a45b 100644 --- a/src/commands.py +++ b/src/commands.py @@ -507,7 +507,8 @@ def getSomething(irc, msg, args, state, errorMsg=None, p=None): def getSomethingNoSpaces(irc, msg, args, state, *L): def p(s): return len(s.split(None, 1)) == 1 - getSomething(irc, msg, args, state, p=p, *L) + errmsg = 'You must not give a string containing spaces as an argument.' + getSomething(irc, msg, args, state, p=p, errorMsg=errmsg, *L) def private(irc, msg, args, state): if irc.isChannel(msg.args[0]): From c73ead8aef234fe11e29ced0e47417e106b50d8a Mon Sep 17 00:00:00 2001 From: James McCoy Date: Fri, 23 Aug 2013 21:35:35 -0400 Subject: [PATCH 61/67] supybot-botchk: Directly execute supybot instead of running it in sh Signed-off-by: James McCoy --- RELNOTES | 2 ++ scripts/supybot-botchk | 21 +++++++-------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/RELNOTES b/RELNOTES index 1e030d6f9..fd4647142 100644 --- a/RELNOTES +++ b/RELNOTES @@ -10,6 +10,8 @@ Factoids' config variable supybot.plugins.Factoids.factoidPrefix has been replaced by supybot.plugins.Factoids.format, which allows the user to determine exactly how replies to Factoid queries are formatted. +supybot-botchk no longer runs supybot inside an instance of /bin/sh. + Version 0.83.4.1 diff --git a/scripts/supybot-botchk b/scripts/supybot-botchk index d61a96e81..f7f262ca8 100644 --- a/scripts/supybot-botchk +++ b/scripts/supybot-botchk @@ -122,21 +122,14 @@ if __name__ == '__main__': debug('pidfile (%s) is not writable: %s' % (options.pidfile, e)) sys.exit(-1) debug('Bot not found, starting.') - home = os.environ['HOME'] - inst = subprocess.Popen('sh', close_fds=True, stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, stdout=subprocess.PIPE) - for filename in ('.login', '.bash_profile', '.profile', '.bashrc'): - filename = os.path.join(home, filename) - if os.path.exists(filename): - debug('Found %s, sourcing.' % filename) - inst.stdin.write('source %s' % filename + os.linesep) - cmdline = '%s --daemon %s' % (options.supybot, options.conffile) - debug('Sending cmdline to sh process.') - inst.stdin.write(cmdline + os.linesep) - inst.stdin.close() - debug('Received from sh process: %r' % inst.stdout.read()) + cmdline = [options.supybot, '--daemon', options.conffile] + inst = subprocess.Popen(cmdline, close_fds=True, + stderr=subprocess.STDOUT, + stdin=None, stdout=subprocess.PIPE) + debug('Output from supybot: %r' % inst.stdout.read()) ret = inst.wait() - debug('Bot started, command line %r returned %s.' % (cmdline, ret)) + debug('Bot started, command line %r returned %s.' % (' '.join(cmdline), + ret)) sys.exit(ret) else: sys.exit(0) From e421722960267cabed14d9b8c09dcf5dcc5a0c42 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Fri, 23 Aug 2013 22:06:07 -0400 Subject: [PATCH 62/67] Fix crash if a custom error message is provided to somethingWithoutSpaces Signed-off-by: James McCoy --- src/commands.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands.py b/src/commands.py index 71d66a45b..6f4675679 100644 --- a/src/commands.py +++ b/src/commands.py @@ -504,11 +504,12 @@ def getSomething(irc, msg, args, state, errorMsg=None, p=None): else: state.args.append(args.pop(0)) -def getSomethingNoSpaces(irc, msg, args, state, *L): +def getSomethingNoSpaces(irc, msg, args, state, errorMsg=None): def p(s): return len(s.split(None, 1)) == 1 - errmsg = 'You must not give a string containing spaces as an argument.' - getSomething(irc, msg, args, state, p=p, errorMsg=errmsg, *L) + if errorMsg is None: + errorMsg='You must not give a string containing spaces as an argument.' + getSomething(irc, msg, args, state, errorMsg=errorMsg, p=p) def private(irc, msg, args, state): if irc.isChannel(msg.args[0]): From 333067c1516bbd136a3a7b91550baac537734e70 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Fri, 23 Aug 2013 22:57:13 -0400 Subject: [PATCH 63/67] Provide a default help message for commands without help. Signed-off-by: James McCoy --- src/callbacks.py | 5 ++++- test/test_callbacks.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/callbacks.py b/src/callbacks.py index be7ebf361..a0d8ddb8e 100644 --- a/src/callbacks.py +++ b/src/callbacks.py @@ -214,7 +214,10 @@ def getHelp(method, name=None, doc=None): if name is None: name = method.__name__ if doc is None: - doclines = method.__doc__.splitlines() + if method.__doc__ is None: + doclines = ['This command has no help. Complain to the author.'] + else: + doclines = method.__doc__.splitlines() else: doclines = doc.splitlines() s = '%s %s' % (name, doclines.pop(0)) diff --git a/test/test_callbacks.py b/test/test_callbacks.py index 4d1c815b9..60d5d4138 100644 --- a/test/test_callbacks.py +++ b/test/test_callbacks.py @@ -550,6 +550,9 @@ class SourceNestedPluginTestCase(PluginTestCase): """ irc.reply('f') + def empty(self, irc, msg, args): + pass + class g(callbacks.Commands): def h(self, irc, msg, args): """takes no arguments @@ -588,6 +591,7 @@ class SourceNestedPluginTestCase(PluginTestCase): self.assertResponse('e g h', 'h') self.assertResponse('e g i j', 'j') self.assertHelp('help f') + self.assertHelp('help empty') self.assertHelp('help same') self.assertHelp('help e g h') self.assertHelp('help e g i j') From 58e7e61d69c085a31e5b3d67a5527a5fa2c194a7 Mon Sep 17 00:00:00 2001 From: Arnout Engelen Date: Fri, 4 May 2012 10:26:09 +0200 Subject: [PATCH 64/67] Only reset the cached feed when the response actually contains headlines Some feeds, such as those from sourceforge.net, will sometimes show an error page rather than a feed. In this case the feed cache used to be cleared for that feed, causing all 'old' headlines to be flooded to the channel as soon as the feed came back online. This patch hopefully fixes that by only resetting the cache when the returned page actually contains headlines. Signed-off-by: James McCoy --- plugins/RSS/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index e2fd5eeca..bb328663e 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -236,7 +236,7 @@ class RSS(callbacks.Plugin): # These seem mostly harmless. We'll need reports of a # kind that isn't. self.log.debug('Allowing bozo_exception %r through.', e) - if results.get('feed', {}): + if results.get('feed', {}) and self.getHeadlines(results): self.cachedFeeds[url] = results self.lastRequest[url] = time.time() else: From e7d0bfd2d0f1a5ac924a16305ac7d7e69e1c6d92 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Fri, 23 Aug 2013 23:36:44 -0400 Subject: [PATCH 65/67] commands: Handle OverflowError in _int Signed-off-by: James McCoy --- src/commands.py | 5 ++++- test/test_commands.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands.py b/src/commands.py index 6f4675679..ca079d7a0 100644 --- a/src/commands.py +++ b/src/commands.py @@ -211,7 +211,10 @@ def _int(s): return int(s, base) except ValueError: if base == 10: - return int(float(s)) + try: + return int(float(s)) + except OverflowError: + raise ValueError('I don\'t understand numbers that large.') else: raise diff --git a/test/test_commands.py b/test/test_commands.py index 290ae5c93..31c9a04fc 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -72,6 +72,7 @@ class GeneralContextTestCase(CommandsTestCase): def testSpecInt(self): self.assertState(['int'], ['1'], [1]) self.assertState(['int', 'int', 'int'], ['1', '2', '3'], [1, 2, 3]) + self.assertError(['int'], ['9e999']) def testSpecNick(self): strict = conf.supybot.protocols.irc.strictRfc() From a483fef39d7ac8079fc59fe7dcc7e1beba63527f Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 24 Aug 2013 00:04:29 -0400 Subject: [PATCH 66/67] RSS: Ensure results is declared before trying to access it Signed-off-by: James McCoy --- plugins/RSS/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/RSS/plugin.py b/plugins/RSS/plugin.py index bb328663e..e96bc80f3 100644 --- a/plugins/RSS/plugin.py +++ b/plugins/RSS/plugin.py @@ -222,6 +222,7 @@ class RSS(callbacks.Plugin): # and DoS the website in question. self.acquireLock(url) if self.willGetNewFeed(url): + results = {} try: self.log.debug('Downloading new feed from %u', url) results = feedparser.parse(url) From ea7f1f8419e6272e7c9dccd39fd3a4e0093fe957 Mon Sep 17 00:00:00 2001 From: James McCoy Date: Sat, 24 Aug 2013 00:29:16 -0400 Subject: [PATCH 67/67] Socket: Match the expected API of reconnect driver.reconnect(wait=True) should flag a driver to reconnect, but not immediately. The Socket driver lost its handling of this flag in 8730832e. Signed-off-by: James McCoy --- src/drivers/Socket.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/drivers/Socket.py b/src/drivers/Socket.py index ad4514d11..207a38a5a 100644 --- a/src/drivers/Socket.py +++ b/src/drivers/Socket.py @@ -152,7 +152,7 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): def connect(self, **kwargs): self.reconnect(reset=False, **kwargs) - def reconnect(self, reset=True): + def reconnect(self, wait=False, reset=True): self.nextReconnectTime = None if self.connected: drivers.log.reconnect(self.irc.network) @@ -163,6 +163,9 @@ class SocketDriver(drivers.IrcDriver, drivers.ServersMixin): self.irc.reset() else: drivers.log.debug('Not resetting %s.', self.irc) + if wait: + self.scheduleReconnect() + return server = self._getNextServer() host = server[0] address = None