1###
2# Copyright (c) 2002-2004, Jeremiah Fincher
3# Copyright (c) 2008-2009, James McCoy
4# All rights reserved.
5#
6# Redistribution and use in source and binary forms, with or without
7# modification, are permitted provided that the following conditions are met:
8#
9#   * Redistributions of source code must retain the above copyright notice,
10#     this list of conditions, and the following disclaimer.
11#   * Redistributions in binary form must reproduce the above copyright notice,
12#     this list of conditions, and the following disclaimer in the
13#     documentation and/or other materials provided with the distribution.
14#   * Neither the name of the author of this software nor the name of
15#     contributors to this software may be used to endorse or promote products
16#     derived from this software without specific prior written consent.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28# POSSIBILITY OF SUCH DAMAGE.
29###
30
31from __future__ import division
32
33import re
34import math
35import cmath
36import types
37import string
38
39import supybot.utils as utils
40from supybot.commands import *
41import supybot.utils.minisix as minisix
42import supybot.callbacks as callbacks
43from supybot.i18n import PluginInternationalization, internationalizeDocstring
44_ = PluginInternationalization('Math')
45
46from .local import convertcore
47
48baseArg = ('int', 'base', lambda i: i <= 36)
49
50class Math(callbacks.Plugin):
51    """Provides commands to work with math, such as a calculator and
52    a unit converter."""
53    @internationalizeDocstring
54    def base(self, irc, msg, args, frm, to, number):
55        """<fromBase> [<toBase>] <number>
56
57        Converts <number> from base <fromBase> to base <toBase>.
58        If <toBase> is left out, it converts to decimal.
59        """
60        if not number:
61            number = str(to)
62            to = 10
63        try:
64            irc.reply(self._convertBaseToBase(number, to, frm))
65        except ValueError:
66            irc.error(_('Invalid <number> for base %s: %s') % (frm, number))
67    base = wrap(base, [('int', 'base', lambda i: 2 <= i <= 36),
68                       optional(('int', 'base', lambda i: 2 <= i <= 36), 10),
69                       additional('something')])
70
71    def _convertDecimalToBase(self, number, base):
72        """Convert a decimal number to another base; returns a string."""
73        if number == 0:
74            return '0'
75        elif number < 0:
76            negative = True
77            number = -number
78        else:
79            negative = False
80        digits = []
81        while number != 0:
82            digit = number % base
83            if digit >= 10:
84                digit = string.ascii_uppercase[digit - 10]
85            else:
86                digit = str(digit)
87            digits.append(digit)
88            number = number // base
89        digits.reverse()
90        return '-'*negative + ''.join(digits)
91
92    def _convertBaseToBase(self, number, toBase, fromBase):
93        """Convert a number from any base, 2 through 36, to any other
94        base, 2 through 36. Returns a string."""
95        number = minisix.long(str(number), fromBase)
96        if toBase == 10:
97            return str(number)
98        return self._convertDecimalToBase(number, toBase)
99
100    _mathEnv = {'__builtins__': types.ModuleType('__builtins__'), 'i': 1j}
101    _mathEnv.update(math.__dict__)
102    _mathEnv.update(cmath.__dict__)
103    def _sqrt(x):
104        if isinstance(x, complex) or x < 0:
105            return cmath.sqrt(x)
106        else:
107            return math.sqrt(x)
108    def _cbrt(x):
109        return math.pow(x, 1.0/3)
110    def _factorial(x):
111        if x<=10000:
112            return float(math.factorial(x))
113        else:
114            raise Exception('factorial argument too large')
115    _mathEnv['sqrt'] = _sqrt
116    _mathEnv['cbrt'] = _cbrt
117    _mathEnv['abs'] = abs
118    _mathEnv['max'] = max
119    _mathEnv['min'] = min
120    _mathEnv['round'] = lambda x, y=0: round(x, int(y))
121    _mathSafeEnv = dict([(x,y) for x,y in _mathEnv.items()])
122    _mathSafeEnv['factorial'] = _factorial
123    _mathRe = re.compile(r'((?:(?<![A-Fa-f\d)])-)?'
124                         r'(?:0x[A-Fa-f\d]+|'
125                         r'0[0-7]+|'
126                         r'\d+\.\d+|'
127                         r'\.\d+|'
128                         r'\d+\.|'
129                         r'\d+))')
130    def _floatToString(self, x):
131        if -1e-10 < x < 1e-10:
132            return '0'
133        elif -1e-10 < int(x) - x < 1e-10:
134            return str(int(x))
135        else:
136            return str(x)
137
138    def _complexToString(self, x):
139        realS = self._floatToString(x.real)
140        imagS = self._floatToString(x.imag)
141        if imagS == '0':
142            return realS
143        elif imagS == '1':
144            imagS = '+i'
145        elif imagS == '-1':
146            imagS = '-i'
147        elif x.imag < 0:
148            imagS = '%si' % imagS
149        else:
150            imagS = '+%si' % imagS
151        if realS == '0' and imagS == '0':
152            return '0'
153        elif realS == '0':
154            return imagS.lstrip('+')
155        elif imagS == '0':
156            return realS
157        else:
158            return '%s%s' % (realS, imagS)
159
160    _calc_match_forbidden_chars = re.compile('[_\[\]]')
161    _calc_remover = utils.str.MultipleRemover('_[] \t')
162    ###
163    # So this is how the 'calc' command works:
164    # First, we make a nice little safe environment for evaluation; basically,
165    # the names in the 'math' and 'cmath' modules.  Then, we remove the ability
166    # of a random user to get ints evaluated: this means we have to turn all
167    # int literals (even octal numbers and hexadecimal numbers) into floats.
168    # Then we delete all square brackets, underscores, and whitespace, so no
169    # one can do list comprehensions or call __...__ functions.
170    ###
171    @internationalizeDocstring
172    def calc(self, irc, msg, args, text):
173        """<math expression>
174
175        Returns the value of the evaluated <math expression>.  The syntax is
176        Python syntax; the type of arithmetic is floating point.  Floating
177        point arithmetic is used in order to prevent a user from being able to
178        crash to the bot with something like '10**10**10**10'.  One consequence
179        is that large values such as '10**24' might not be exact.
180        """
181        try:
182            text = str(text)
183        except UnicodeEncodeError:
184            irc.error(_("There's no reason you should have fancy non-ASCII "
185                            "characters in your mathematical expression. "
186                            "Please remove them."))
187            return
188        if self._calc_match_forbidden_chars.match(text):
189            # Note: this is important to keep this to forbid usage of
190            # __builtins__
191            irc.error(_('There\'s really no reason why you should have '
192                           'underscores or brackets in your mathematical '
193                           'expression.  Please remove them.'))
194            return
195        text = self._calc_remover(text)
196        if 'lambda' in text:
197            irc.error(_('You can\'t use lambda in this command.'))
198            return
199        text = text.lower()
200        def handleMatch(m):
201            s = m.group(1)
202            if s.startswith('0x'):
203                i = int(s, 16)
204            elif s.startswith('0') and '.' not in s:
205                try:
206                    i = int(s, 8)
207                except ValueError:
208                    i = int(s)
209            else:
210                i = float(s)
211            x = complex(i)
212            if x.imag == 0:
213                x = x.real
214                # Need to use string-formatting here instead of str() because
215                # use of str() on large numbers loses information:
216                # str(float(33333333333333)) => '3.33333333333e+13'
217                # float('3.33333333333e+13') => 33333333333300.0
218                return '%.16f' % x
219            return str(x)
220        text = self._mathRe.sub(handleMatch, text)
221        try:
222            self.log.info('evaluating %q from %s', text, msg.prefix)
223            x = complex(eval(text, self._mathSafeEnv, self._mathSafeEnv))
224            irc.reply(self._complexToString(x))
225        except OverflowError:
226            maxFloat = math.ldexp(0.9999999999999999, 1024)
227            irc.error(_('The answer exceeded %s or so.') % maxFloat)
228        except TypeError:
229            irc.error(_('Something in there wasn\'t a valid number.'))
230        except NameError as e:
231            irc.error(_('%s is not a defined function.') % str(e).split()[1])
232        except Exception as e:
233            irc.error(str(e))
234    calc = wrap(calc, ['text'])
235
236    @internationalizeDocstring
237    def icalc(self, irc, msg, args, text):
238        """<math expression>
239
240        This is the same as the calc command except that it allows integer
241        math, and can thus cause the bot to suck up CPU.  Hence it requires
242        the 'trusted' capability to use.
243        """
244        if self._calc_match_forbidden_chars.match(text):
245            # Note: this is important to keep this to forbid usage of
246            # __builtins__
247            irc.error(_('There\'s really no reason why you should have '
248                           'underscores or brackets in your mathematical '
249                           'expression.  Please remove them.'))
250            return
251        # This removes spaces, too, but we'll leave the removal of _[] for
252        # safety's sake.
253        text = self._calc_remover(text)
254        if 'lambda' in text:
255            irc.error(_('You can\'t use lambda in this command.'))
256            return
257        text = text.replace('lambda', '')
258        try:
259            self.log.info('evaluating %q from %s', text, msg.prefix)
260            irc.reply(str(eval(text, self._mathEnv, self._mathEnv)))
261        except OverflowError:
262            maxFloat = math.ldexp(0.9999999999999999, 1024)
263            irc.error(_('The answer exceeded %s or so.') % maxFloat)
264        except TypeError:
265            irc.error(_('Something in there wasn\'t a valid number.'))
266        except NameError as e:
267            irc.error(_('%s is not a defined function.') % str(e).split()[1])
268        except Exception as e:
269            irc.error(utils.exnToString(e))
270    icalc = wrap(icalc, [('checkCapability', 'trusted'), 'text'])
271
272    _rpnEnv = {
273        'dup': lambda s: s.extend([s.pop()]*2),
274        'swap': lambda s: s.extend([s.pop(), s.pop()])
275        }
276    def rpn(self, irc, msg, args):
277        """<rpn math expression>
278
279        Returns the value of an RPN expression.
280        """
281        stack = []
282        for arg in args:
283            try:
284                x = complex(arg)
285                if x == abs(x):
286                    x = abs(x)
287                stack.append(x)
288            except ValueError: # Not a float.
289                if arg in self._mathSafeEnv:
290                    f = self._mathSafeEnv[arg]
291                    if callable(f):
292                        called = False
293                        arguments = []
294                        while not called and stack:
295                            arguments.append(stack.pop())
296                            try:
297                                stack.append(f(*arguments))
298                                called = True
299                            except TypeError:
300                                pass
301                        if not called:
302                            irc.error(_('Not enough arguments for %s') % arg)
303                            return
304                    else:
305                        stack.append(f)
306                elif arg in self._rpnEnv:
307                    self._rpnEnv[arg](stack)
308                else:
309                    arg2 = stack.pop()
310                    arg1 = stack.pop()
311                    s = '%s%s%s' % (arg1, arg, arg2)
312                    try:
313                        stack.append(eval(s, self._mathSafeEnv, self._mathSafeEnv))
314                    except SyntaxError:
315                        irc.error(format(_('%q is not a defined function.'),
316                                         arg))
317                        return
318        if len(stack) == 1:
319            irc.reply(str(self._complexToString(complex(stack[0]))))
320        else:
321            s = ', '.join(map(self._complexToString, list(map(complex, stack))))
322            irc.reply(_('Stack: [%s]') % s)
323
324    @internationalizeDocstring
325    def convert(self, irc, msg, args, number, unit1, unit2):
326        """[<number>] <unit> to <other unit>
327
328        Converts from <unit> to <other unit>. If number isn't given, it
329        defaults to 1. For unit information, see 'units' command.
330        """
331        try:
332            digits = len(str(number).split('.')[1])
333        except IndexError:
334            digits = 0
335        try:
336            newNum = convertcore.convert(number, unit1, unit2)
337            if isinstance(newNum, float):
338                zeros = 0
339                for char in str(newNum).split('.')[1]:
340                    if char != '0':
341                        break
342                    zeros += 1
343                # Let's add one signifiant digit. Physicists would not like
344                # that, but common people usually do not give extra zeros...
345                # (for example, with '32 C to F', an extra digit would be
346                # expected).
347                newNum = round(newNum, digits + 1 + zeros)
348            newNum = self._floatToString(newNum)
349            irc.reply(str(newNum))
350        except convertcore.UnitDataError as ude:
351            irc.error(str(ude))
352    convert = wrap(convert, [optional('float', 1.0),'something','to','text'])
353
354    @internationalizeDocstring
355    def units(self, irc, msg, args, type):
356        """ [<type>]
357
358        With no arguments, returns a list of measurement types, which can be
359        passed as arguments. When called with a type as an argument, returns
360        the units of that type.
361        """
362
363        irc.reply(convertcore.units(type))
364    units = wrap(units, [additional('text')])
365
366Class = Math
367
368# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
369