sopel.modules.currency.exchange()   F
last analyzed

Complexity

Conditions 14

Size

Total Lines 69
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 55
dl 0
loc 69
rs 3.6
c 0
b 0
f 0
cc 14
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like sopel.modules.currency.exchange() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# coding=utf-8
2
"""
3
currency.py - Sopel Currency Conversion Module
4
Copyright 2013, Elsie Powell, embolalia.com
5
Copyright 2019, Mikkel Jeppesen
6
Licensed under the Eiffel Forum License 2.
7
8
https://sopel.chat
9
"""
10
from __future__ import unicode_literals, absolute_import, print_function, division
11
12
import logging
13
import re
14
import time
15
16
import requests
17
18
from sopel.config.types import StaticSection, ValidatedAttribute
19
from sopel.module import commands, example, NOLIMIT, rule
20
21
22
FIAT_URL = 'https://api.exchangeratesapi.io/latest?base=EUR'
23
FIXER_URL = 'http://data.fixer.io/api/latest?base=EUR&access_key={}'
24
CRYPTO_URL = 'https://api.coingecko.com/api/v3/exchange_rates'
25
EXCHANGE_REGEX = re.compile(r'''
26
    ^(\d+(?:\.\d+)?)                                            # Decimal number
27
    \s*([a-zA-Z]{3})                                            # 3-letter currency code
28
    \s+(?:in|as|of|to)\s+                                       # preposition
29
    (([a-zA-Z]{3}$)|([a-zA-Z]{3})\s)+$                          # one or more 3-letter currency code
30
''', re.VERBOSE)
31
LOGGER = logging.getLogger(__name__)
32
UNSUPPORTED_CURRENCY = "Sorry, {} isn't currently supported."
33
UNRECOGNIZED_INPUT = "Sorry, I didn't understand the input."
34
35
rates = {}
36
rates_updated = 0.0
37
38
39
class CurrencySection(StaticSection):
40
    fixer_io_key = ValidatedAttribute('fixer_io_key', default=None)
41
    """Optional API key for Fixer.io (increases currency support)"""
42
    auto_convert = ValidatedAttribute('auto_convert', parse=bool, default=False)
43
    """Whether to convert currencies without an explicit command"""
44
45
46
def configure(config):
47
    """
48
    | name | example | purpose |
49
    | ---- | ------- | ------- |
50
    | auto\\_convert | False | Whether to convert currencies without an explicit command |
51
    | fixer\\_io\\_key | 0123456789abcdef0123456789abcdef | Optional API key for Fixer.io (increases currency support) |
52
    """
53
    config.define_section('currency', CurrencySection, validate=False)
54
    config.currency.configure_setting('fixer_io_key', 'Optional API key for Fixer.io (leave blank to use exchangeratesapi.io):')
55
    config.currency.configure_setting('auto_convert', 'Whether to convert currencies without an explicit command?')
56
57
58
def setup(bot):
59
    bot.config.define_section('currency', CurrencySection)
60
61
62
class FixerError(Exception):
63
    """A Fixer.io API Error Exception"""
64
    def __init__(self, status):
65
        super(FixerError, self).__init__("FixerError: {}".format(status))
66
67
68
class UnsupportedCurrencyError(Exception):
69
    """A currency is currently not supported by the API"""
70
    def __init__(self, currency):
71
        super(UnsupportedCurrencyError, self).__init__(currency)
72
73
74
def update_rates(bot):
75
    global rates, rates_updated
76
77
    # If we have data that is less than 24h old, return
78
    if time.time() - rates_updated < 24 * 60 * 60:
79
        return
80
81
    # Update crypto rates
82
    response = requests.get(CRYPTO_URL)
83
    response.raise_for_status()
84
    rates_crypto = response.json()
85
86
    # Update fiat rates
87
    if bot.config.currency.fixer_io_key is not None:
88
        response = requests.get(FIXER_URL.format(bot.config.currency.fixer_io_key))
89
        if not response.json()['success']:
90
            raise FixerError('Fixer.io request failed with error: {}'.format(response.json()['error']))
91
    else:
92
        response = requests.get(FIAT_URL)
93
94
    response.raise_for_status()
95
    rates_fiat = response.json()
96
    rates_updated = time.time()
97
98
    rates = rates_fiat['rates']
99
    rates['EUR'] = 1.0  # Put this here to make logic easier
100
101
    eur_btc_rate = 1 / rates_crypto['rates']['eur']['value']
102
103
    for rate in rates_crypto['rates']:
104
        if rate.upper() not in rates:
105
            rates[rate.upper()] = rates_crypto['rates'][rate]['value'] * eur_btc_rate
106
107
108
def get_rate(base, target):
109
    base = base.upper()
110
    target = target.upper()
111
112
    if base not in rates:
113
        raise UnsupportedCurrencyError(base)
114
115
    if target not in rates:
116
        raise UnsupportedCurrencyError(target)
117
118
    return (1 / rates[base]) * rates[target]
119
120
121
def exchange(bot, match):
122
    """Show the exchange rate between two currencies"""
123
    if not match:
124
        bot.reply(UNRECOGNIZED_INPUT)
125
        return NOLIMIT
126
127
    try:
128
        update_rates(bot)  # Try and update rates. Rate-limiting is done in update_rates()
129
    except requests.exceptions.RequestException as err:
130
        bot.reply("Something went wrong while I was getting the exchange rate.")
131
        LOGGER.error("Error in GET request: {}".format(err))
132
        return NOLIMIT
133
    except ValueError:
134
        bot.reply("Error: Got malformed data.")
135
        LOGGER.error("Invalid json on update_rates")
136
        return NOLIMIT
137
    except FixerError as err:
138
        bot.reply('Sorry, something went wrong with Fixer')
139
        LOGGER.error(err)
140
        return NOLIMIT
141
142
    query = match.string
143
144
    targets = query.split()
145
    amount = targets.pop(0)
146
    base = targets.pop(0)
147
    targets.pop(0)
148
149
    # TODO: Use this instead after dropping Python 2 support
150
    # amount, base, _, *targets = query.split()
151
152
    try:
153
        amount = float(amount)
154
    except ValueError:
155
        bot.reply(UNRECOGNIZED_INPUT)
156
        return NOLIMIT
157
    except OverflowError:
158
        bot.reply("Sorry, input amount was out of range.")
159
        return NOLIMIT
160
161
    if not amount:
162
        bot.reply("Zero is zero, no matter what country you're in.")
163
        return NOLIMIT
164
165
    out_string = '{} {} is'.format(amount, base.upper())
166
167
    unsupported_currencies = []
168
    for target in targets:
169
        try:
170
            out_string = build_reply(amount, base.upper(), target.upper(), out_string)
171
        except ValueError:
172
            LOGGER.error("Raw rate wasn't a float")
173
            return NOLIMIT
174
        except KeyError as err:
175
            bot.reply("Error: Invalid rates")
176
            LOGGER.error("No key: {} in json".format(err))
177
            return NOLIMIT
178
        except UnsupportedCurrencyError as cur:
179
            unsupported_currencies.append(cur)
180
181
    if unsupported_currencies:
182
        out_string = out_string + ' (unsupported:'
183
        for target in unsupported_currencies:
184
            out_string = out_string + ' {},'.format(target)
185
        out_string = out_string[0:-1] + ')'
186
    else:
187
        out_string = out_string[0:-1]
188
189
    bot.reply(out_string)
190
191
192
@commands('cur', 'currency', 'exchange')
193
@example('.cur 100 usd in btc cad eur',
194
         r'100\.0 USD is [\d\.]+ BTC, [\d\.]+ CAD, [\d\.]+ EUR',
195
         re=True)
196
@example('.cur 100 usd in btc cad eur can aux',
197
         r'100\.0 USD is [\d\.]+ BTC, [\d\.]+ CAD, [\d\.]+ EUR, \(unsupported: CAN, AUX\)',
198
         re=True)
199
def exchange_cmd(bot, trigger):
200
    if not trigger.group(2):
201
        return bot.reply("No search term. Usage: {}cur 100 usd in btc cad eur"
202
                         .format(bot.config.core.help_prefix))
203
204
    match = EXCHANGE_REGEX.match(trigger.group(2))
205
    exchange(bot, match)
206
207
208
@rule(EXCHANGE_REGEX)
209
@example('100 usd in btc cad eur')
210
def exchange_re(bot, trigger):
211
    if bot.config.currency.auto_convert:
212
        match = EXCHANGE_REGEX.match(trigger)
213
        exchange(bot, match)
214
215
216
def build_reply(amount, base, target, out_string):
217
    rate_raw = get_rate(base, target)
218
    rate = float(rate_raw)
219
    result = float(rate * amount)
220
221
    if target == 'BTC':
222
        return out_string + ' {:.5f} {},'.format(result, target)
223
224
    return out_string + ' {:.2f} {},'.format(result, target)
225