From 85aa7c326494f71453f6e1f7400ccfbbb1b2f069 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 10 Jan 2023 12:54:05 -0700 Subject: [PATCH] feat: Added currency support (#6) --- README.md | 4 +- __init__.py | 167 +++++++++++++++++++++++++++++++++++++++------ icons/currency.svg | 11 +++ 3 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 icons/currency.svg diff --git a/README.md b/README.md index 30ed38c..b4c6b5b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License MIT](https://custom-icon-badges.herokuapp.com/github/license/DenverCoder1/unit-converter-albert-ext.svg?logo=repo)](https://github.com/DenverCoder1/unit-converter-albert-ext/blob/main/LICENSE) [![code style black](https://custom-icon-badges.herokuapp.com/badge/code%20style-black-black.svg?logo=black-b&logoColor=white)](https://github.com/psf/black) -Extension for converting units of length, mass, speed, temperature, time, current, luminosity, printing measurements, molecular substance, in [Albert launcher](https://albertlauncher.github.io/) +Extension for converting units of length, mass, speed, temperature, time, current, luminosity, printing measurements, molecular substance, currency, and more in [Albert launcher](https://albertlauncher.github.io/) ![demo](https://user-images.githubusercontent.com/20955511/147166860-2550fe42-ba6f-4ae6-a305-5e5ed26b606b.gif) @@ -60,6 +60,8 @@ Examples: `convert 3.14159 rad to degrees` +`convert 100 EUR to USD` + To configure the trigger to be something other than "convert ", open the `Triggers` tab in the Albert settings. ![configure trigger](https://user-images.githubusercontent.com/20955511/211632106-981ce5a8-0311-47d5-aefe-3ab9d669fc3f.png) diff --git a/__init__.py b/__init__.py index 223d9c3..60d31bb 100644 --- a/__init__.py +++ b/__init__.py @@ -5,19 +5,22 @@ import json import re import traceback +from datetime import datetime from pathlib import Path from typing import Any +from urllib.request import urlopen +from xml.etree import ElementTree import albert -import pint import inflect +import pint default_trigger = "convert " synopsis = " to " __doc__ = f""" Extension for converting units of length, mass, speed, temperature, time, -current, luminosity, printing measurements, molecular substance, and more +current, luminosity, printing measurements, molecular substance, currency, and more Synopsis: {default_trigger}{synopsis} @@ -27,12 +30,13 @@ `{default_trigger}88 mph to kph` `{default_trigger}32 degrees F to C` `{default_trigger}3.14159 rad to degrees` +`{default_trigger}100 USD to EUR` """ md_iid = "0.5" -md_version = "1.1" +md_version = "1.2" md_name = "Unit Converter" -md_description = "Convert length, mass, speed, temperature, time, and more" +md_description = "Convert length, mass, temperature, time, currency, and more" md_license = "MIT" md_url = "https://github.com/DenverCoder1/unit-converter-albert-ext" md_lib_dependencies = ["pint", "inflect"] @@ -69,51 +73,56 @@ class ConversionResult: def __init__( self, from_amount: float, - from_unit: pint.Unit, + from_unit: str, to_amount: float, - to_unit: pint.Unit, + to_unit: str, + dimensionality: str, ): """ Initialize the ConversionResult Args: from_amount (float): The amount to convert from - from_unit (Unit): The unit to convert from + from_unit (str): The unit to convert from to_amount (float): The resulting amount - to_unit (Unit): The unit converted to + to_unit (str): The unit converted to + dimensionality (str): The dimensionality of the result """ self.from_amount = from_amount self.from_unit = from_unit self.to_amount = to_amount self.to_unit = to_unit - self.dimensionality = units._get_dimensionality(to_unit) + self.dimensionality = dimensionality self.display_names = config.get("display_names", {}) self.rounding_precision = int(config.get("rounding_precision", 3)) self.rounding_precision_zero = int(config.get("rounding_precision_zero", 12)) - def __pluralize_unit(self, unit: pint.Unit) -> str: + def __pluralize_unit(self, unit: str) -> str: """ Pluralize the unit Args: - unit (Unit): The unit to pluralize + unit (str): The unit to pluralize Returns: str: The pluralized unit """ - return inflect_engine.plural(str(unit)) + # if all characters are uppercase, don't pluralize + if unit.isupper(): + return unit + return inflect_engine.plural(unit) - def __display_unit_name(self, amount: float, unit: pint.Unit) -> str: + def __display_unit_name(self, amount: float, unit: str) -> str: """ Display the name of the unit with plural if necessary Args: - unit (Unit): The unit to display + unit (str): The unit to display Returns: str: The name of the unit """ - unit = self.__pluralize_unit(unit) if amount != 1 else str(unit) + unit = self.__pluralize_unit(unit) if amount != 1 else unit return self.display_names.get(unit, unit) def __format_float(self, num: float) -> str: @@ -161,7 +170,7 @@ def icon(self) -> str: Return the icon for the result's dimensionality """ # strip characters from the dimensionality if not alphanumeric or underscore - dimensionality = re.sub(r"[^\w]", "", str(self.dimensionality)) + dimensionality = re.sub(r"[^\w]", "", self.dimensionality) return f"{dimensionality}.svg" def __repr__(self): @@ -222,13 +231,119 @@ def convert_units(self, amount: float, from_unit: str, to_unit: str) -> Conversi result = input_unit.to(output_unit) return ConversionResult( from_amount=float(amount), - from_unit=self._get_unit(from_unit), + from_unit=str(self._get_unit(from_unit)), to_amount=result.magnitude, - to_unit=result.units, + to_unit=str(result.units), + dimensionality=str(units._get_dimensionality(result.units)), + ) + + +class UnknownCurrencyError(Exception): + def __init__(self, currency: str): + """ + Initialize the UnknownCurrencyError + + Args: + currency (str): The unknown currency + """ + self.currency = currency + super().__init__(f"Unknown currency: {currency}") + + +class CurrencyConverter: + + API_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" + + def __init__(self): + """ + Initialize the CurrencyConverter + """ + self.last_update = datetime.now() + self.aliases: dict[str, str] = config.get("aliases", {}) + self.currencies = self._get_currencies() + + def _get_currencies(self) -> dict[str, float]: + """ + Get the currencies from the API + + Returns: + dict[str, float]: The currencies + """ + with urlopen(self.API_URL) as response: + xml = response.read().decode("utf-8").strip() + root = ElementTree.fromstring(xml) + currency_cube = root[-1][0] + if currency_cube is None: + albert.warning("Could not find currencies in XML") + return {} + currencies = { + currency.attrib["currency"]: float(currency.attrib["rate"]) + for currency in currency_cube + } + currencies["EUR"] = 1 + albert.info(f"Loaded currencies: {currencies}") + return currencies + + def normalize_currency(self, currency: str) -> str | None: + """ + Get the currency name normalized using aliases + + Args: + currency (str): The currency to normalize + + Returns: + Optional[str]: The currency name or None if not found + """ + currency = self.aliases.get(currency, currency).upper() + return currency if currency in self.currencies else None + + def convert_currency( + self, amount: float, from_currency: str, to_currency: str + ) -> ConversionResult: + """ + Convert a currency to another currency + + Args: + amount (float): The amount to convert + from_currency (str): The currency to convert from + to_currency (str): The currency to convert to + + Returns: + str: The resulting amount in the new currency + + Raises: + UnknownCurrencyError: If the currency is not valid + """ + # update the currencies every 24 hours + if (datetime.now() - self.last_update).days >= 1: + self.currencies = self._get_currencies() + self.last_update = datetime.now() + # get the currency rates + from_unit = self.normalize_currency(from_currency) + to_unit = self.normalize_currency(to_currency) + # convert the currency + if from_unit is None: + raise UnknownCurrencyError(from_currency) + if to_unit is None: + raise UnknownCurrencyError(to_currency) + from_rate = self.currencies[from_unit] + to_rate = self.currencies[to_unit] + result = amount * to_rate / from_rate + return ConversionResult( + from_amount=float(amount), + from_unit=from_unit, + to_amount=result, + to_unit=to_unit, + dimensionality="currency", ) class Plugin(albert.QueryHandler): + def initialize(self): + """Initialize the plugin.""" + self.unit_converter = UnitConverter() + self.currency_converter = CurrencyConverter() + def id(self) -> str: return __name__ @@ -307,10 +422,16 @@ def get_items(self, amount: float, from_unit: str, to_unit: str) -> list[albert. Returns: List[albert.Item]: The list of items to display """ - uc = UnitConverter() try: - # convert the units - result = uc.convert_units(amount, from_unit, to_unit) + # convert currencies + if ( + self.currency_converter.normalize_currency(from_unit) is not None + and self.currency_converter.normalize_currency(to_unit) is not None + ): + result = self.currency_converter.convert_currency(amount, from_unit, to_unit) + # convert standard units + else: + result = self.unit_converter.convert_units(amount, from_unit, to_unit) # return the result return [ self.create_item( @@ -329,3 +450,7 @@ def get_items(self, amount: float, from_unit: str, to_unit: str) -> list[albert. albert.warning(f"UndefinedUnitError: {e}") albert.warning(traceback.format_exc()) return [] + except UnknownCurrencyError as e: + albert.warning(f"UnknownCurrencyError: {e}") + albert.warning(traceback.format_exc()) + return [] diff --git a/icons/currency.svg b/icons/currency.svg new file mode 100644 index 0000000..51a9f40 --- /dev/null +++ b/icons/currency.svg @@ -0,0 +1,11 @@ + + + + + + +