├── .env ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── SettingsTemplate.yaml ├── demo.png ├── main.py ├── packdeps.cmd ├── plugin.json ├── requirements.txt ├── settings.png └── src ├── CurrencyPP.png ├── currencyparser.py ├── currencypp.py ├── exchange.py ├── parsy.py └── webservice.py /.env: -------------------------------------------------------------------------------- 1 | PYTHONPATH=./lib -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ master ] 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | env: 12 | python_ver: 3.8 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: get version 21 | id: version 22 | uses: notiz-dev/github-action-json-property@release 23 | with: 24 | path: 'plugin.json' 25 | prop_path: 'Version' 26 | - run: echo ${{steps.version.outputs.prop}} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r ./requirements.txt -t ./lib 31 | zip -r Flow.Launcher.Plugin.CurrencyPP.zip . -x '*.git*' 32 | - name: Publish 33 | if: success() 34 | uses: softprops/action-gh-release@v1 35 | with: 36 | files: 'Flow.Launcher.Plugin.CurrencyPP.zip' 37 | tag_name: "v${{steps.version.outputs.prop}}" 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,vscode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,vscode 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | pytestdebug.log 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # pyenv 61 | .python-version 62 | 63 | # pipenv 64 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 65 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 66 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 67 | # install all needed dependencies. 68 | #Pipfile.lock 69 | 70 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 71 | __pypackages__/ 72 | 73 | # Environments 74 | .venv 75 | env/ 76 | venv/ 77 | ENV/ 78 | venv.bak/ 79 | pythonenv* 80 | 81 | # pytype static type analyzer 82 | .pytype/ 83 | 84 | # profiling data 85 | .prof 86 | 87 | # End of https://www.toptal.com/developers/gitignore/api/python,vscode 88 | 89 | plugin.log -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.autopep8Args": [ 3 | "--ignore", 4 | "E402" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Arthur Vedana 4 | Copyright (c) 2022 Le Loc Tai 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flow.Launcher.Plugin.CurrencyPP 2 | 3 | ![demo](./demo.png) 4 | ![settings](./settings.png) 5 | 6 | A port of [Keypirinha Currency plugin](https://github.com/AvatarHurden/keypirinha-currency) to Flow launcher. 7 | 8 | Compared to the existing [Currency Converter](https://github.com/deefrawley/Flow.Launcher.Plugin.Currency) plugin: 9 | - Support [more currencies](https://docs.openexchangerates.org/reference/supported-currencies) 10 | - Output multiple currencies 11 | - Allow setting default currencies to convert from/to 12 | - Customizable aliases. For example, 1$ can be configured to be USD or AUD 13 | - Support math (see below) 14 | 15 | Below is an excerpt the original readme 16 | 17 | --- 18 | 19 | ## Usage 20 | 21 | For the most basic usage, simply enter the amount to convert, the source currency and the destination currency, such as `5 USD in EUR`. 22 | You can perform mathematical operations for the source amount, such as `10*(2+1) usd in EUR`, and you can even perform some math on the resulting amount `5 usd in EUR / 2`. 23 | 24 | Furthermore, you can add (or subtract) multiple currencies together, such as `5 USD + 2 GBP in EUR`. 25 | You can also convert into multiple destination currencies, such as `5 USD in EUR, GBP`, and each conversion will be displayed as a separate result. 26 | 27 | If you omit the name of a currency, such as in `5 USD` or `5 in USD`, the plugin will use the default currencies specified in the configuration file. 28 | You can also change what words and symbols are used between multiple destination currencies and between the source and destination. 29 | 30 | ### Aliases 31 | 32 | By default, the plugin operates only on [ISO currency codes](https://pt.wikipedia.org/wiki/ISO_4217) (and a few others). 33 | However, there is support for *aliases*, which are alternative names for currencies. 34 | In the configuration file, the user can specify as many aliases as they desire for any currency (for instance, `dollar` and `dollars` for USD). 35 | Aliases, just like regular currency codes, are case-insensitive (i.e. `EuR`, `EUR` and `eur` are all treated the same). 36 | 37 | 38 | ### Math 39 | 40 | The available mathematical operations are addition (`+`), subtraction (`-`), multiplication (`*`), division (`/`) and exponentiation (`**` or `^`). 41 | You can also use parentheses and the negative operator (`-(3 + 4) * 4`, for example). 42 | 43 | ### Grammar 44 | 45 | For those familiar with BNF grammars and regex, below is grammar accepted by the parser (`prog` is the top-level expression): 46 | 47 | ``` 48 | prog := sources (to_key? destinations)? extra? 49 | 50 | to_key := 'to' | 'in' | ':' 51 | 52 | destinations := cur_code sep destinations | cur_code 53 | 54 | sep := ',' | '&' | 'and' 55 | cur_code := ([^0-9\s+-/*^()]+) 56 | # excluding any words that are used as 'sep' or 'to_key' 57 | 58 | extra := ('+' | '-' | '*' | '/' | '**' | '^' ) expr 59 | 60 | sources := source ('+' | '-') sources | source 61 | source := '(' source ')' 62 | | cur_code expr 63 | | expr (cur_code?) 64 | 65 | expr := add_expr 66 | add_expr := mult_expr | add_expr ('+' | '-') mult_expr 67 | mult_expr := exp_expr | mult_expr ('*' | '/') exp_expr 68 | exp_expr := unary_expr | exp_expr ('^' | '**') unary_expr 69 | unary_expr := operand | ('-' | '+') unary_expr 70 | operand := number | '(' expr ')' 71 | 72 | number := (0|[1-9][0-9]*)([.,][0-9]+)?([eE][+-]?[0-9]+)? 73 | ``` 74 | 75 | ## Backend 76 | 77 | The Currency plugin uses [OpenExchangeRates](https://openexchangerates.org/) to obtain hourly exchange rates for all currencies. Since this project does not make any money, it is using the free tier, which only allows 1000 requests per month. In order to allow the most number of people to use the plugin without any work, there is a cache layer that reduces the number of requests to the backend. 78 | 79 | If this cache layer fails, however, the plugin quickly runs into this request limit. In order to work around this issue, the plugin allows users to specify their own App ID to use whenever the cache is older than 2 hours. This shouldn't happen often, but is a safeguard in case things go wrong. Users can get a free App ID by creating an account [here](https://openexchangerates.org/signup/free). 80 | 81 | ## Change Log 82 | 83 | ### v2.2 84 | * Added workaround for situations in which the cache fails. 85 | 86 | ### v2.1 87 | 88 | * Improved grammar for more intuitive use 89 | * Bug fixes 90 | * Improved options to copy results to clipboard 91 | 92 | ### v2.0 93 | 94 | * Improved parser. More flexible, and now you can specify your own separators in the config file 95 | * Math! Add, subtract, multiply, or divide numbers to obtain the source amount for a currency (also supports parentheses and exponents) 96 | * Multiple source currencies. Add or subtract amounts in different currencies to obtain a final result 97 | * An icon 98 | * Support for aliases. The user can create aliases ('nicknames') for any valid currency in the config file 99 | 100 | 101 | ### v1.4 102 | 103 | * Added a layer between clients and OpenExchangeRates to mitigate API usage 104 | 105 | ### v1.3 106 | 107 | * Changed API from Yahoo Finance to OpenExchangeRates 108 | 109 | ### v1.2 110 | 111 | * Saves exchange information locally, updating automatically or manually 112 | * Allow converting currencies directly in the search 113 | 114 | ### v1.1 115 | 116 | * Allow decimal amounts to be inserted (using either a comma or a period) 117 | * Added copy actions 118 | * Added configuration for default currencies 119 | * Multiple source and destination currencies can be specified 120 | 121 | ### v1.0 122 | 123 | * Initial Release 124 | -------------------------------------------------------------------------------- /SettingsTemplate.yaml: -------------------------------------------------------------------------------- 1 | body: 2 | - type: dropdown 3 | attributes: 4 | name: update_freq 5 | label: Update Frequency 6 | description: The frequency at which rates are automatically updated 7 | defaultValue: daily 8 | options: 9 | - never 10 | - hourly 11 | - daily 12 | - type: input 13 | attributes: 14 | name: input_cur 15 | label: Default input currency 16 | description: The ISO 4217 code of the default source currency to assume if none is specified at search time 17 | defaultValue: 'USD' 18 | - type: input 19 | attributes: 20 | name: output_cur 21 | label: Default output currency 22 | description: The ISO 4217 code of the default output currency to assume if none is specified at search time. Separate each currency code by any whitespace 23 | defaultValue: 'EUR GBP' 24 | - type: input 25 | attributes: 26 | name: separators 27 | label: Separator 28 | description: The valid separators between sources and destination. Separate values with whitespace 29 | defaultValue: 'to in :' 30 | - type: input 31 | attributes: 32 | name: destination_separators 33 | label: Destination Separator 34 | description: The valid separators between multiple destination currencies. Separate values with whitespace 35 | defaultValue: 'and & ,' 36 | - type: input 37 | attributes: 38 | name: app_id 39 | label: OpenExchangeRates App ID 40 | description: > 41 | A custom OpenExchangeRates App ID to use whenever the cache layer fails. 42 | If this is not specified, the plugin will only use the cache layer, which has 43 | no guarantees to work. The free layer should be sufficient for most users. 44 | defaultValue: '' 45 | - type: textarea 46 | attributes: 47 | name: aliases 48 | label: Aliases 49 | description: > 50 | Aliases are alternative names for currencies, allowing you to enter simpler 51 | names instead of having to memorize currency codes 52 | 53 | For instance, you can write the local name (both singular and plural) for your 54 | most used currencies. You can also use '$' to represent your local currency 55 | 56 | Aliases are case-insensitive, can be any length and can be composed of any 57 | character except numbers. Separate aliases with whitespace and newline 58 | defaultValue: | 59 | EUR = euro euros 60 | usd = dollar dollars $ bucks -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeLocTai/Flow.Launcher.Plugin.CurrencyPP/ab67f06bfe3a308258209d3a6c51c7c4277e357a/demo.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | parent_folder_path = os.path.abspath(os.path.dirname(__file__)) 4 | sys.path.append(parent_folder_path) 5 | sys.path.append(os.path.join(parent_folder_path, 'lib')) 6 | sys.path.append(os.path.join(parent_folder_path, 'src')) 7 | 8 | from currencypp import CurrencyPP 9 | 10 | if __name__ == "__main__": 11 | CurrencyPP() 12 | -------------------------------------------------------------------------------- /packdeps.cmd: -------------------------------------------------------------------------------- 1 | pip install -r ./requirements.txt -t ./lib -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "ID": "84d9d550-80cb-4e5f-a090-e1ccf3237a40", 3 | "ActionKeyword": "*", 4 | "Name": "CurrencyPP", 5 | "Description": "A better currency converter", 6 | "Author": "Le Loc Tai", 7 | "Version": "3.0.1", 8 | "Language": "python", 9 | "Website": "https://github.com/LeLocTai/Flow.Launcher.Plugin.CurrencyPP", 10 | "IcoPath": "src/CurrencyPP.png", 11 | "ExecuteFileName": "main.py" 12 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flox_lib==0.18.1 2 | -------------------------------------------------------------------------------- /settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeLocTai/Flow.Launcher.Plugin.CurrencyPP/ab67f06bfe3a308258209d3a6c51c7c4277e357a/settings.png -------------------------------------------------------------------------------- /src/CurrencyPP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeLocTai/Flow.Launcher.Plugin.CurrencyPP/ab67f06bfe3a308258209d3a6c51c7c4277e357a/src/CurrencyPP.png -------------------------------------------------------------------------------- /src/currencyparser.py: -------------------------------------------------------------------------------- 1 | from parsy import regex, generate, alt, string, seq, Parser, Result 2 | import operator 3 | 4 | # Grammar 5 | # 6 | # prog := sources (to_key? destinations)? extra? 7 | # 8 | # to_key := 'to' | 'in' | ':' 9 | # 10 | # destinations := cur_code sep destinations | cur_code 11 | # 12 | # sep := ',' | '&' | 'and' 13 | # cur_code := ([^0-9\s+-/*^()]+)\b(?> expression << rparen))) 148 | 149 | @generate 150 | def source(): 151 | amount_first = seq(expression, code.optional()) 152 | curr_first = seq(code, expression.optional()).map(lambda a: a[::-1]) 153 | pure = (amount_first | curr_first).map(lambda a: { 154 | 'amount': a[0], 155 | 'currency': a[1] 156 | }) 157 | ret = yield pure | lparen >> source << rparen 158 | return ret 159 | 160 | @generate 161 | def sources(): 162 | first = yield lexeme(source) 163 | 164 | @generate 165 | def more_sources(): 166 | op = yield (s('+') | s('-')) 167 | rest = yield sources 168 | return op, rest 169 | 170 | more = yield more_sources.optional() 171 | rest = None 172 | if more: 173 | op, rest = more 174 | if op == '-': 175 | rest[0]['amount'] *= -1 176 | return [first] + (rest if rest else []) 177 | 178 | @generate 179 | def destinations(): 180 | first = yield lexeme(code) 181 | rest = yield (sep_parser() >> destinations).optional() 182 | return [{'currency': first}] + (rest if rest else []) 183 | 184 | @generate 185 | def extra(): 186 | operations = {'+': operator.add, 187 | '-': operator.sub, 188 | '**': operator.pow, 189 | '*': operator.mul, 190 | '/': operator.truediv, 191 | '^': operator.pow} 192 | op = yield alt(*[s(k).result(v) for (k, v) in operations.items()]) 193 | expr = yield expression 194 | return { 195 | 'operation': op, 196 | 'value': expr 197 | } 198 | 199 | @generate 200 | def parser(): 201 | source = yield sources 202 | destination = yield (to_parser().optional() >> destinations).optional() 203 | extras = yield extra.optional() 204 | if extras: 205 | op = extras['operation'] 206 | value = extras['value'] 207 | for s in source: 208 | s['amount'] = op(s['amount'], value) 209 | return { 210 | 'sources': source, 211 | 'destinations': destination 212 | } 213 | 214 | return parser 215 | -------------------------------------------------------------------------------- /src/currencypp.py: -------------------------------------------------------------------------------- 1 | from exchange import ExchangeRates, UpdateFreq, CurrencyError 2 | from flox.utils import cache_path 3 | from parsy import ParseError 4 | from currencyparser import make_parser, ParserProperties 5 | from flox import Flox, clipboard 6 | 7 | 8 | class CurrencyPP(Flox): 9 | 10 | broker = None 11 | 12 | def __init__(self): 13 | super().__init__() 14 | # self.logger_level("debug") 15 | self._read_config() 16 | 17 | # actions = [ 18 | # self.create_action( 19 | # name=self.ACTION_COPY_RESULT, 20 | # label="Copy result with code", 21 | # short_desc="Copy result (with code) to clipboard"), 22 | # self.create_action( 23 | # name=self.ACTION_COPY_AMOUNT, 24 | # label="Copy numerical result", 25 | # short_desc="Copy numerical result to clipboard"), 26 | # self.create_action( 27 | # name=self.ACTION_COPY_EQUATION, 28 | # label="Copy conversion", 29 | # short_desc="Copy conversion equation to clipboard")] 30 | 31 | # self.set_actions(self.ITEMCAT_RESULT, actions) 32 | 33 | def query(self, user_input): 34 | try: 35 | query = self._parse_and_merge_input(user_input, True) 36 | # This tests whether the user entered enough information to 37 | # indicate a currency conversion request. 38 | if not self._is_direct_request(query): 39 | return 40 | # if the conversion would have failed, return now 41 | self.broker.convert(self._parse_and_merge_input(user_input)) 42 | except CurrencyError: 43 | return 44 | except Exception as e: 45 | # self.logger.error("convert error:\n" + 46 | # "\n".join(traceback.format_exception(e))) 47 | return 48 | 49 | try: 50 | query = self._parse_and_merge_input(user_input) 51 | if query['destinations'] is None or query['sources'] is None: 52 | return 53 | 54 | if self.broker.tryUpdate(): 55 | self._update_update_item() 56 | 57 | if self.broker.error: 58 | self.add_item("Webservice failed", 59 | '{}'.format(self.broker.error)) 60 | else: 61 | results = self.broker.convert(query) 62 | 63 | for result in results: 64 | self.add_item( 65 | result['title'], 66 | result['description'], 67 | context=result['description'], 68 | method=self.item_action, 69 | parameters=[result['amount']], 70 | score=100, 71 | ) 72 | except Exception as exc: 73 | self.add_item("query", "Error: " + str(exc)) 74 | 75 | self.add_item( 76 | 'Update Currency', 77 | 'Last updated at ' + self.broker.last_update.isoformat(), 78 | method=self.update_rates, 79 | parameters=[user_input], 80 | dont_hide=True 81 | ) 82 | 83 | def item_action(self, amount): 84 | clipboard.put(str(amount)) 85 | 86 | def update_rates(self, last_query): 87 | self.broker.update() 88 | self.change_query(str(last_query), True) 89 | 90 | def _is_direct_request(self, query): 91 | entered_dest = ('destinations' in query and 92 | query['destinations'] is not None) 93 | entered_source = (query['sources'] is not None and 94 | len(query['sources']) > 0 and 95 | query['sources'][0]['currency'] is not None) 96 | 97 | return entered_dest or entered_source 98 | 99 | def _parse_and_merge_input(self, user_input=None, empty=False): 100 | if empty: 101 | query = {'sources': None} 102 | else: 103 | query = { 104 | 'sources': [{'currency': self.broker.default_cur_in, 'amount': 1.0}], 105 | 'destinations': [{'currency': cur} for cur in self.broker.default_curs_out], 106 | 'extra': None 107 | } 108 | 109 | if not user_input: 110 | return query 111 | 112 | user_input = user_input.lstrip() 113 | 114 | try: 115 | parsed = self.parser.parse(user_input) 116 | if not parsed['destinations'] and 'destinations' in query: 117 | parsed['destinations'] = query['destinations'] 118 | return parsed 119 | except ParseError: 120 | return query 121 | 122 | def _read_config(self): 123 | def _warn_cur_code(name, fallback): 124 | fmt = "Invalid {} value in config. Falling back to default: {}" 125 | self.logger.warning(fmt.format(name, fallback)) 126 | 127 | self.update_freq = UpdateFreq(self.settings.get('update_freq')) 128 | 129 | app_id_key = self.settings.get('app_id').strip() 130 | 131 | cache_dir = cache_path(self.name) 132 | cache_dir.mkdir(exist_ok=True) 133 | self.broker = ExchangeRates( 134 | cache_dir, self.update_freq, app_id_key, self) 135 | 136 | input_code = self.settings.get('input_cur').strip() 137 | validated_input_code = self.broker.set_default_cur_in(input_code) 138 | 139 | if not validated_input_code: 140 | _warn_cur_code("input_cur", self.broker.default_cur_in) 141 | 142 | output_code = self.settings.get('output_cur').strip() 143 | validated_output_code = self.broker.set_default_curs_out(output_code) 144 | 145 | if not validated_output_code: 146 | _warn_cur_code("output_cur", self.broker.default_curs_out) 147 | 148 | # separators 149 | separators_string = self.settings.get('separators').strip() 150 | separators = separators_string.split() 151 | 152 | # destination_separators 153 | dest_seps_string = self.settings.get('destination_separators').strip() 154 | dest_separators = dest_seps_string.split() 155 | 156 | # aliases 157 | self.broker.clear_aliases() 158 | 159 | aliases_string = self.settings.get('aliases') 160 | 161 | for line in aliases_string.splitlines(): 162 | try: 163 | key, aliases_string = line.split('=') 164 | key = key.strip() 165 | aliases_string = aliases_string.strip() 166 | 167 | validatedKey = self.broker.validate_code(key) 168 | aliases = aliases_string.split() 169 | 170 | for alias in aliases: 171 | validated = self.broker.validate_alias(alias) 172 | if validated: 173 | self.broker.add_alias(validated, validatedKey) 174 | else: 175 | self.logger.warning( 176 | 'Alias {} is invalid. It will be ignored'.format(alias)) 177 | except Exception: 178 | self.logger.warning( 179 | 'Key {} is not a valid currency. It will be ignored'.format(key)) 180 | 181 | properties = ParserProperties() 182 | properties.to_keywords = separators 183 | properties.sep_keywords = dest_separators 184 | self.parser = make_parser(properties) 185 | 186 | 187 | if __name__ == "__main__": 188 | CurrencyPP() 189 | -------------------------------------------------------------------------------- /src/exchange.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from webservice import OpenExchangeRates, PrivateDomain 4 | 5 | import json 6 | import os 7 | import re 8 | 9 | 10 | class CurrencyError(RuntimeError): 11 | def __init__(self, currency): 12 | self.currency = currency 13 | 14 | def __str__(self): 15 | return 'Unrecognized currency "{}". You can create aliases in the package configuration file.'.format(self.currency) 16 | 17 | 18 | class UpdateFreq(Enum): 19 | NEVER = 'never' 20 | HOURLY = 'hourly' 21 | DAILY = 'daily' 22 | 23 | 24 | class ExchangeRates(): 25 | 26 | _file_path = None 27 | last_update = None 28 | update_freq = None 29 | _currencies = {} 30 | _aliases = {} 31 | 32 | in_cur_fallback = 'USD' 33 | out_cur_fallback = 'EUR GBP' 34 | 35 | default_cur_in = 'USD' 36 | default_curs_out = ['EUR', 'GBP'] 37 | 38 | error = None 39 | 40 | def __init__(self, path, update_freq, app_id, plugin): 41 | self.plugin = plugin 42 | self.cheap_service = PrivateDomain(self.plugin) 43 | self.expensive_service = OpenExchangeRates(self.plugin, app_id) 44 | self.update_freq = update_freq 45 | self._file_path = os.path.join(path, 'rates.json') 46 | 47 | if os.path.exists(self._file_path): 48 | try: 49 | self.load_from_file() 50 | except Exception as e: 51 | self.update() 52 | else: 53 | self.update() 54 | 55 | self.tryUpdate() 56 | 57 | def shouldUpdate(self): 58 | time_diff = datetime.now() - self.last_update 59 | if self.update_freq.value == UpdateFreq.HOURLY.value: 60 | return time_diff.total_seconds() >= 3600 61 | elif self.update_freq.value == UpdateFreq.DAILY.value: 62 | return time_diff.days >= 1 63 | else: 64 | return False 65 | 66 | def tryUpdate(self): 67 | if not self.last_update: 68 | return True 69 | if self.shouldUpdate(): 70 | return self.update() 71 | else: 72 | return False 73 | 74 | def update(self): 75 | try: 76 | try: 77 | self._currencies, update_time = self.cheap_service.load_from_url() 78 | self.last_update = datetime.now() 79 | time_diff = self.last_update - \ 80 | datetime.fromtimestamp(update_time) 81 | except Exception as e: 82 | self.plugin.logger.info( 83 | 'cache server has returned error. Requesting from main API') 84 | self.plugin.logger.error(e) 85 | if not self.has_custom_app_id(): 86 | return False 87 | self._currencies, update_time = self.expensive_service.load_from_url() 88 | 89 | if (time_diff.total_seconds() > 3600 * 2): 90 | self.plugin.logger.info( 91 | 'cache server is more than 2 hours old. Requesting from main API') 92 | if not self.has_custom_app_id(): 93 | return False 94 | self._currencies, update_time = self.expensive_service.load_from_url() 95 | 96 | self.save_to_file() 97 | self.error = None 98 | return True 99 | except Exception as e: 100 | self.plugin.logger.error(e) 101 | self.error = e 102 | return False 103 | 104 | def has_custom_app_id(self): 105 | if self.expensive_service.app_id: 106 | return True 107 | self.plugin.logger.error( 108 | 'No OpenExchangeRates App ID declared in the configuration file.') 109 | self.error = Exception( 110 | 'The cache has failed. More information (and a fix) are available in the Currency plugin configuration file.') 111 | return False 112 | 113 | def load_from_file(self): 114 | with open(self._file_path) as f: 115 | data = json.load(f) 116 | 117 | self.last_update = datetime.strptime( 118 | data['last_update'], '%Y-%m-%dT%H:%M:%S') 119 | self._currencies = data['rates'] 120 | self._load_secondary_data() 121 | 122 | def _load_secondary_data(self): 123 | pass 124 | 125 | def save_to_file(self): 126 | data = { 127 | 'rates': self._currencies, 128 | 'last_update': self.last_update.strftime('%Y-%m-%dT%H:%M:%S') 129 | } 130 | 131 | with open(self._file_path, 'w') as f: 132 | json.dump(data, f) 133 | 134 | def rate(self, code): 135 | if code == 'USD': 136 | return 1 137 | else: 138 | if code in self._aliases: 139 | return self.rate(self._aliases[code]) 140 | else: 141 | return self._currencies[code]['price'] 142 | 143 | def name(self, code): 144 | if code in self._aliases: 145 | return self.name(self._aliases[code]) 146 | else: 147 | return self._currencies[code]['name'] 148 | 149 | def format_codes(self, codeString): 150 | lst = [x.strip() for x in codeString.split(',')] 151 | return lst 152 | 153 | def clear_aliases(self): 154 | self._aliases.clear() 155 | 156 | def validate_alias(self, alias): 157 | validated = alias.upper() 158 | if len(validated) < 1: 159 | return None 160 | elif validated in self._currencies: 161 | return None 162 | elif validated in self._aliases: 163 | return None 164 | elif re.search('\d', validated): 165 | return None 166 | else: 167 | return validated 168 | 169 | def add_alias(self, alias, forCurrency): 170 | validatedCurrency = self.validate_code(forCurrency) 171 | self._aliases[alias] = validatedCurrency 172 | 173 | def validate_code(self, codeString, raiseOnNone=False): 174 | if codeString is None: 175 | if raiseOnNone: 176 | raise CurrencyError(None) 177 | return self.default_cur_in 178 | elif codeString.upper() in self._currencies or codeString.upper() in self._aliases: 179 | return codeString.upper() 180 | else: 181 | raise CurrencyError(codeString) 182 | 183 | def format_number(self, number, fullDigits=False): 184 | if fullDigits: 185 | formatted = '{:,.8f}'.format(number).rstrip('0').rstrip('.') 186 | else: 187 | formatted = '{:,.2f}'.format(number).rstrip('.') 188 | return formatted 189 | 190 | def convert(self, query): 191 | results = [] 192 | for destination in query['destinations']: 193 | destinationCode = self.validate_code(destination['currency'], True) 194 | total = 0 195 | srcDescription = '' 196 | for index, source in enumerate(query['sources']): 197 | sourceCode = self.validate_code(source['currency']) 198 | rate = self.rate(destinationCode) / self.rate(sourceCode) 199 | amount = source['amount'] if source['amount'] else 1 200 | convertedAmount = rate * amount 201 | total += convertedAmount 202 | if amount < 0 or index > 0: 203 | srcDescription += ' - ' if amount < 0 else ' + ' 204 | srcDescription += '{} {}'.format(self.format_number(abs(amount)), 205 | self.name(sourceCode)) 206 | 207 | fullDigits = len(query['sources']) == 1 and \ 208 | (query['sources'][0]['amount'] or 1) == 1 209 | 210 | formatted_total = self.format_number(total, fullDigits) 211 | result = { 212 | 'amount': total, 213 | 'description': srcDescription, 214 | 'title': '{}'.format(formatted_total + ' ' + self.name(destinationCode)) 215 | } 216 | results.append(result) 217 | return results 218 | 219 | def set_default_cur_in(self, string): 220 | code = string.upper() 221 | if code in self._currencies.keys(): 222 | self.default_cur_in = code 223 | return True 224 | else: 225 | return False 226 | 227 | def set_default_curs_out(self, string): 228 | lst = string.split() 229 | curs = [x.upper() for x in lst if x.upper() in self._currencies.keys()] 230 | if len(lst) != len(curs): 231 | return False 232 | self.default_curs_out = curs 233 | return True 234 | -------------------------------------------------------------------------------- /src/parsy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- # 2 | 3 | # End-user documentation is in ../../doc/ and so is for the most part not 4 | # duplicated here in the form of doc strings. Code comments and docstrings 5 | # are mainly for internal use. 6 | 7 | import operator 8 | import re 9 | import sys 10 | from collections import namedtuple 11 | from functools import wraps 12 | 13 | noop = lambda x: x 14 | 15 | def line_info_at(stream, index): 16 | if index > len(stream): 17 | raise ValueError("invalid index") 18 | line = stream.count("\n", 0, index) 19 | last_nl = stream.rfind("\n", 0, index) 20 | col = index - (last_nl + 1) 21 | return (line, col) 22 | 23 | 24 | class ParseError(RuntimeError): 25 | def __init__(self, expected, stream, index): 26 | self.expected = expected 27 | self.stream = stream 28 | self.index = index 29 | 30 | def line_info(self): 31 | try: 32 | return '{}:{}'.format(*line_info_at(self.stream, self.index)) 33 | except (TypeError, AttributeError): # not a str 34 | return str(self.index) 35 | 36 | def __str__(self): 37 | expected_list = sorted(repr(e) for e in self.expected) 38 | 39 | if len(expected_list) == 1: 40 | return 'expected {} at {}'.format(expected_list[0], self.line_info()) 41 | else: 42 | return 'expected one of {} at {}'.format(', '.join(expected_list), self.line_info()) 43 | 44 | 45 | class Result(namedtuple('Result', 'status index value furthest expected')): 46 | @staticmethod 47 | def success(index, value): 48 | return Result(True, index, value, -1, frozenset()) 49 | 50 | @staticmethod 51 | def failure(index, expected): 52 | return Result(False, -1, None, index, frozenset([expected])) 53 | 54 | # collect the furthest failure from self and other 55 | def aggregate(self, other): 56 | if not other: 57 | return self 58 | 59 | if self.furthest > other.furthest: 60 | return self 61 | elif self.furthest == other.furthest: 62 | # if we both have the same failure index, we combine the expected messages. 63 | return Result(self.status, self.index, self.value, self.furthest, self.expected | other.expected) 64 | else: 65 | return Result(self.status, self.index, self.value, other.furthest, other.expected) 66 | 67 | 68 | class Parser(object): 69 | """ 70 | A Parser is an object that wraps a function whose arguments are 71 | a string to be parsed and the index on which to begin parsing. 72 | The function should return either Result.success(next_index, value), 73 | where the next index is where to continue the parse and the value is 74 | the yielded value, or Result.failure(index, expected), where expected 75 | is a string indicating what was expected, and the index is the index 76 | of the failure. 77 | """ 78 | 79 | def __init__(self, wrapped_fn): 80 | self.wrapped_fn = wrapped_fn 81 | 82 | def __call__(self, stream, index): 83 | return self.wrapped_fn(stream, index) 84 | 85 | def parse(self, stream): 86 | """Parse a string or list of tokens and return the result or raise a ParseError.""" 87 | (result, _) = (self << eof).parse_partial(stream) 88 | return result 89 | 90 | def parse_partial(self, stream): 91 | """ 92 | Parse the longest possible prefix of a given string. 93 | Return a tuple of the result and the rest of the string, 94 | or raise a ParseError. 95 | """ 96 | result = self(stream, 0) 97 | 98 | if result.status: 99 | return (result.value, stream[result.index:]) 100 | else: 101 | raise ParseError(result.expected, stream, result.furthest) 102 | 103 | def bind(self, bind_fn): 104 | @Parser 105 | def bound_parser(stream, index): 106 | result = self(stream, index) 107 | 108 | if result.status: 109 | next_parser = bind_fn(result.value) 110 | return next_parser(stream, result.index).aggregate(result) 111 | else: 112 | return result 113 | 114 | return bound_parser 115 | 116 | def map(self, map_fn): 117 | return self.bind(lambda res: success(map_fn(res))) 118 | 119 | def combine(self, combine_fn): 120 | return self.bind(lambda res: success(combine_fn(*res))) 121 | 122 | def combine_dict(self, combine_fn): 123 | return self.bind(lambda res: success(combine_fn(**{k: v for k, v in dict(res).items() 124 | if k is not None}))) 125 | 126 | def concat(self): 127 | return self.map(''.join) 128 | 129 | def then(self, other): 130 | return seq(self, other).combine(lambda left, right: right) 131 | 132 | def skip(self, other): 133 | return seq(self, other).combine(lambda left, right: left) 134 | 135 | def result(self, res): 136 | return self >> success(res) 137 | 138 | def many(self): 139 | return self.times(0, float('inf')) 140 | 141 | def times(self, min, max=None): 142 | if max is None: 143 | max = min 144 | 145 | @Parser 146 | def times_parser(stream, index): 147 | values = [] 148 | times = 0 149 | result = None 150 | 151 | while times < max: 152 | result = self(stream, index).aggregate(result) 153 | if result.status: 154 | values.append(result.value) 155 | index = result.index 156 | times += 1 157 | elif times >= min: 158 | break 159 | else: 160 | return result 161 | 162 | return Result.success(index, values).aggregate(result) 163 | 164 | return times_parser 165 | 166 | def at_most(self, n): 167 | return self.times(0, n) 168 | 169 | def at_least(self, n): 170 | return self.times(n) + self.many() 171 | 172 | def optional(self): 173 | return self.times(0, 1).map(lambda v: v[0] if v else None) 174 | 175 | def sep_by(self, sep, *, min=0, max=float('inf')): 176 | zero_times = success([]) 177 | if max == 0: 178 | return zero_times 179 | res = self.times(1) + (sep >> self).times(min - 1, max - 1) 180 | if min == 0: 181 | res |= zero_times 182 | return res 183 | 184 | def desc(self, description): 185 | @Parser 186 | def desc_parser(stream, index): 187 | result = self(stream, index) 188 | if result.status: 189 | return result 190 | else: 191 | return Result.failure(index, description) 192 | 193 | return desc_parser 194 | 195 | def mark(self): 196 | @generate 197 | def marked(): 198 | start = yield line_info 199 | body = yield self 200 | end = yield line_info 201 | return (start, body, end) 202 | 203 | return marked 204 | 205 | def tag(self, name): 206 | return self.map(lambda v: (name, v)) 207 | 208 | def should_fail(self, description): 209 | @Parser 210 | def fail_parser(stream, index): 211 | res = self(stream, index) 212 | if res.status: 213 | return Result.failure(index, description) 214 | return Result.success(index, res) 215 | 216 | return fail_parser 217 | 218 | def __add__(self, other): 219 | return seq(self, other).combine(operator.add) 220 | 221 | def __mul__(self, other): 222 | if isinstance(other, range): 223 | return self.times(other.start, other.stop - 1) 224 | return self.times(other) 225 | 226 | def __or__(self, other): 227 | return alt(self, other) 228 | 229 | # haskelley operators, for fun # 230 | 231 | # >> 232 | def __rshift__(self, other): 233 | return self.then(other) 234 | 235 | # << 236 | def __lshift__(self, other): 237 | return self.skip(other) 238 | 239 | 240 | def alt(*parsers): 241 | if not parsers: 242 | return fail('') 243 | 244 | @Parser 245 | def alt_parser(stream, index): 246 | result = None 247 | for parser in parsers: 248 | result = parser(stream, index).aggregate(result) 249 | if result.status: 250 | return result 251 | 252 | return result 253 | 254 | return alt_parser 255 | 256 | 257 | if sys.version_info >= (3, 6): 258 | # Only 3.6 and later supports kwargs that remember their order, 259 | # so only have this kwarg signature on Python 3.6 and above 260 | def seq(*parsers, **kw_parsers): 261 | """ 262 | Takes a list of list of parsers, runs them in order, 263 | and collects their individuals results in a list 264 | """ 265 | if not parsers and not kw_parsers: 266 | return success([]) 267 | 268 | if parsers and kw_parsers: 269 | raise ValueError("Use either positional arguments or keyword arguments with seq, not both") 270 | 271 | if parsers: 272 | @Parser 273 | def seq_parser(stream, index): 274 | result = None 275 | values = [] 276 | for parser in parsers: 277 | result = parser(stream, index).aggregate(result) 278 | if not result.status: 279 | return result 280 | index = result.index 281 | values.append(result.value) 282 | return Result.success(index, values).aggregate(result) 283 | 284 | return seq_parser 285 | else: 286 | @Parser 287 | def seq_kwarg_parser(stream, index): 288 | result = None 289 | values = {} 290 | for name, parser in kw_parsers.items(): 291 | result = parser(stream, index).aggregate(result) 292 | if not result.status: 293 | return result 294 | index = result.index 295 | values[name] = result.value 296 | return Result.success(index, values).aggregate(result) 297 | 298 | return seq_kwarg_parser 299 | 300 | else: 301 | def seq(*parsers): 302 | """ 303 | Takes a list of list of parsers, runs them in order, 304 | and collects their individuals results in a list 305 | """ 306 | if not parsers: 307 | return success([]) 308 | 309 | @Parser 310 | def seq_parser(stream, index): 311 | result = None 312 | values = [] 313 | for parser in parsers: 314 | result = parser(stream, index).aggregate(result) 315 | if not result.status: 316 | return result 317 | index = result.index 318 | values.append(result.value) 319 | 320 | return Result.success(index, values).aggregate(result) 321 | 322 | return seq_parser 323 | 324 | 325 | # combinator syntax 326 | def generate(fn): 327 | if isinstance(fn, str): 328 | return lambda f: generate(f).desc(fn) 329 | 330 | @Parser 331 | @wraps(fn) 332 | def generated(stream, index): 333 | # start up the generator 334 | iterator = fn() 335 | 336 | result = None 337 | value = None 338 | try: 339 | while True: 340 | next_parser = iterator.send(value) 341 | result = next_parser(stream, index).aggregate(result) 342 | if not result.status: 343 | return result 344 | value = result.value 345 | index = result.index 346 | except StopIteration as stop: 347 | returnVal = stop.value 348 | if isinstance(returnVal, Parser): 349 | return returnVal(stream, index).aggregate(result) 350 | 351 | return Result.success(index, returnVal).aggregate(result) 352 | 353 | return generated 354 | 355 | 356 | index = Parser(lambda _, index: Result.success(index, index)) 357 | line_info = Parser(lambda stream, index: Result.success(index, line_info_at(stream, index))) 358 | 359 | 360 | def success(val): 361 | return Parser(lambda _, index: Result.success(index, val)) 362 | 363 | 364 | def fail(expected): 365 | return Parser(lambda _, index: Result.failure(index, expected)) 366 | 367 | 368 | def string(s, transform=noop): 369 | slen = len(s) 370 | transformed_s = transform(s) 371 | 372 | @Parser 373 | def string_parser(stream, index): 374 | if transform(stream[index:index + slen]) == transformed_s: 375 | return Result.success(index + slen, s) 376 | else: 377 | return Result.failure(index, s) 378 | 379 | return string_parser 380 | 381 | 382 | def regex(exp, flags=0): 383 | if isinstance(exp, str): 384 | exp = re.compile(exp, flags) 385 | 386 | @Parser 387 | def regex_parser(stream, index): 388 | match = exp.match(stream, index) 389 | if match: 390 | return Result.success(match.end(), match.group(0)) 391 | else: 392 | return Result.failure(index, exp.pattern) 393 | 394 | return regex_parser 395 | 396 | 397 | def test_item(func, description): 398 | @Parser 399 | def test_item_parser(stream, index): 400 | if index < len(stream): 401 | item = stream[index] 402 | if func(item): 403 | return Result.success(index + 1, item) 404 | return Result.failure(index, description) 405 | 406 | return test_item_parser 407 | 408 | 409 | def test_char(func, description): 410 | # Implementation is identical to test_item 411 | return test_item(func, description) 412 | 413 | 414 | def match_item(item, description=None): 415 | if description is None: 416 | description = str(item) 417 | return test_item(lambda i: item == i, description) 418 | 419 | 420 | def string_from(*strings, transform=noop): 421 | # Sort longest first, so that overlapping options work correctly 422 | return alt(*[string(s, transform) for s in sorted(strings, key=len, reverse=True)]) 423 | 424 | 425 | def char_from(string): 426 | return test_char(lambda c: c in string, "[" + string + "]") 427 | 428 | 429 | any_char = test_char(lambda c: True, "any character") 430 | 431 | whitespace = regex(r'\s+') 432 | 433 | letter = test_char(lambda c: c.isalpha(), 'a letter') 434 | 435 | digit = test_char(lambda c: c.isdigit(), 'a digit') 436 | 437 | decimal_digit = char_from("0123456789") 438 | 439 | 440 | @Parser 441 | def eof(stream, index): 442 | if index >= len(stream): 443 | return Result.success(index, None) 444 | else: 445 | return Result.failure(index, 'EOF') 446 | 447 | 448 | def from_enum(enum_cls, transform=noop): 449 | items = sorted([(str(enum_item.value), enum_item) for enum_item in enum_cls], 450 | key=lambda t: len(t[0]), 451 | reverse=True) 452 | return alt(*[string(value, transform=transform).result(enum_item) 453 | for value, enum_item in items]) 454 | -------------------------------------------------------------------------------- /src/webservice.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | import urllib.request as request 3 | import json 4 | 5 | 6 | class PrivateDomain(): 7 | 8 | url = 'https://arthurvedana.com/currency/latest.json' 9 | 10 | def __init__(self, plugin): 11 | self.plugin = plugin 12 | 13 | def build_request(self): 14 | return self.url 15 | 16 | def load_from_url(self): 17 | self.plugin.logger.info("loading from cache server...") 18 | opener = request.build_opener() 19 | opener.addheaders = [("User-agent", "Mozilla/5.0")] 20 | 21 | requestURL = self.build_request() 22 | 23 | with opener.open(requestURL) as conn: 24 | response = conn.read() 25 | 26 | data = json.loads(response) 27 | rates = data['rates'] 28 | 29 | currencies = {} 30 | for rate in rates: 31 | private_rate = { 32 | 'name': rate, 33 | 'price': rates[rate] 34 | } 35 | currencies[rate] = private_rate 36 | 37 | return currencies, data['timestamp'] 38 | 39 | 40 | class OpenExchangeRates(): 41 | 42 | url = 'https://openexchangerates.org/api/latest.json' 43 | 44 | def __init__(self, plugin, app_id): 45 | self.plugin = plugin 46 | self.app_id = app_id 47 | 48 | def build_request(self, parameters): 49 | return self.url + '?' + urllib.parse.urlencode(parameters) 50 | 51 | def load_from_url(self): 52 | self.plugin.logger.info("loading from API...") 53 | opener = request.build_opener() 54 | 55 | params = {'app_id': self.app_id, 56 | 'show_alternative': True} 57 | 58 | requestURL = self.build_request(params) 59 | 60 | with opener.open(requestURL) as conn: 61 | response = conn.read() 62 | 63 | data = json.loads(response) 64 | rates = data['rates'] 65 | 66 | currencies = {} 67 | for rate in rates: 68 | private_rate = { 69 | 'name': rate, 70 | 'price': rates[rate] 71 | } 72 | currencies[rate] = private_rate 73 | 74 | return currencies, data['timestamp'] 75 | --------------------------------------------------------------------------------