Quickstart Tutorial
In this quick start guide, we'll make a simple commandline currency converter. Knowledge of commandline application development is not required to complete this tutorial. However, a basic understanding of Python and running Python scripts is assumed. It should take about 30 minutes to complete.
By the end of this tutorial, you should know how to:
- Parse and utilize foreign exchange rates, localization, and rounding data for currency conversions and formatting.
- Create money vectors and use them in calculations.
- Display money to a user in a localized format.
Let's get started!
Installation
This tutorial does not use any dependencies outside the standard library other than
linearmoney
.
pip install linearmoney
Boilerplate
Add (or just copy and paste) the following code to a new file called converter.py
:
#!/usr/bin/python
"""Interactive commandline script for converting currencies.
Converts <amount> of <from_currency> to <to_currency>
Usage: [-h|--help] <amount> <from_currency> <to_currency>
"""
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
while True:
readline = input("Currency Converter--> ")
Since we intend for this to be an interactive commandline app, we don't want the
code to execute when imported, so we raise an ImportError
immediately if it is
not running directly from the commandline.
The infinite loop at the end creates our interactive command prompt with the builtin
input
function.
If you've never built a commandline app before, this infinite loop might have you concerned, but this
is a very simple way to emulate a custom command prompt.
We're using the input
builtin to provide a prompt and store whatever string that the user
inputs on the commandline into the readline
variable. We aren't doing anything with this
user input yet, but this is the basic structure of our application. Ctrl+C can be used
to exit the infinite loop.
The docstring outlines what we expect the app to do. We pass in a numeric amount, a currency to convert from, and a currency to convert to, and it converts the value for us, so let's implement that.
Converting Currencies
First off, we'll add linearmoney
to our imports at the top of the file:
...
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
import linearmoney as lm
while True:
readline = input("Currency Converter--> ")
Next, if we are going to convert currencies, we'll need exchange rates. linearmoney
provides a helpful forex
function for coercing a dictionary of exchange rates into a vector that can be
used by the conversion functions, but we still need to get rates from somewhere.
In this case, I'm using the awesome https://theforexapi.com, which is a free public web API for forex rates:
import time
import urllib.request
import json
import linearmoney as lm
def request_rates() -> dict:
"""Request the latest rates from theforexapi.com and
return them as a dict."""
print("Fetching latest forex rates from theforexapi.com...")
print("Waiting 2 seconds to comply with api rate limits...")
time.sleep(2) # Respect API rate limits.
url = "https://theforexapi.com/api/latest"
with urllib.request.urlopen(url) as response:
rate_dict = json.loads(response.read().decode("utf-8"))
return rate_dict
If you haven't made HTTP requests with Python before, don't worry about understanding this code.
The request_rates
function just uses the standard library's
urllib.request
to fetch the latest exchange rates from theforexapi and returns them as a Python dictionary.
We also wait two seconds before making the request to avoid violating the APIs soft rate limits due to a programming oversight such as accidentally calling this function in a loop.
This dictionary is of a particular structure that is common among web APIs for forex rates.
It includes a base
key that indicates the base currency of each exchange rate pair, and
then a rates
key, which is another nested Dictionary where each key is the quote
currency, and the value is the rate from the base currency to the quote currency.
The forex
function accepts this structure of Dictionary
directly, so we can simply pass the result of the request_rates
function into the
forex
function to get a
ForexVector
that we can use
to convert currencies in our script:
forex_vector = lm.vector.forex(request_rates())
currency_space = lm.vector.space(forex_vector)
ForexVectors
represent a specific set of exchange rates in a structure understood
internally by linearmoney and are not intended to be used in
calculations directly.
The currency_space
is another key part of the linearmoney math model. In mathematical
terms, it defines the vector space of all monetary calculations, but in practical
terms, it's basically a strict definition of the allowed currencies for a calculation.
We will see how this is used and enforced later on, but for now we just need to know
that it is using the forex rates we fetched from theforexapi
to determine which currencies
we can calculate/convert.
Commandline Arguments
Just like a single-run commandline script, we'll treat user input as a set of arguments, so we need to process the string that the user provides in our while loop.
Since our script is supposed to convert an amount of one currency to another, we need three arguments:
- The numeric amount to convert.
- The currency code of the currency we are converting from.
- The currency code of the currency we are converting to.
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split() # [<amount>, <from_currency>, <to_currency>]
print(f"amount: {raw_args[0]}")
print(f"from_currency: {raw_args[1]}")
print(f"to_currency: {raw_args[2]}")
This is the most basic way to parse the arguments for our currency converter. Now let's use them to actually convert the amount:
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split() # [<amount>, <from_currency>, <to_currency>]
asset_vector = lm.vector.asset(raw_args[0], raw_args[1], currency_space)
converted_value = lm.vector.evaluate(
asset_vector, raw_args[2], forex_vector
)
to_currency = lm.data.currency(raw_args[2])
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
Let's break down the above code.
asset_vector = lm.vector.asset(raw_args[0], raw_args[1], currency_space)
The asset_vector
uses the
asset
function
to create a new vector representing the monetary amount of the amount
argument
in the currency <from_currency>
.
Notice the use of the currency_space
that we defined earlier. The asset
function
requires us to provide a currency space when creating a vector in order to ensure
that any calculations with other vectors are in the same currency space. For example, if
we create another asset using a different currency space and then we try to add the two
different assets together, we will get a
SpaceError
, and the
same would happen if we tried to evaluate
the asset vector we created using a forex vector that was in a different currency space.
Since we created our currency_space
from our forex_vector
, we can ensure that any math
required to evaluate the asset we create will work correctly by creating the
asset in the same currency space.
converted_value = lm.vector.evaluate(
asset_vector, raw_args[2], forex_vector
)
The call to evaluate
converts the total value of the asset vector to the currency defined by <to_currency>
using the exchange rates in forex_vector
. This function returns a
decimal.Decimal
, not
another asset vector. For the remainder of this tutorial, we
will refer to this as evaluation, not conversion. See also
Evaluation and
Conversion.
In this case, our asset vector was just created and only has one component in one
currency, so it doesn't really matter, but the evaluate
function will give the total
value of the entire asset in the target currency even if our asset vector contained
values in multiple currencies, so this allows us to program more complex applications
in a way where we don't have to worry about what currency a monetary amount is in, we
just call evaluate
to get the value in the currency we want at that point in
time, and it Just Works ™. The same goes for converting assets with
convert
.
to_currency = lm.data.currency(raw_args[2])
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
The l10n
function formats a decimal using the local currency representation for the
provided locale.
The roundas
function rounds
a decimal value based on the provided currency data. We round before localizing to
ensure that the string returned by the l10n
function is correctly formatted with the
rounded value. Both the locale
and the currency are Datasources, and we have
to construct them using the corresponding factory functions.
The currency
function takes an ISO 4217 currency code (e.g. "USD") and returns the rounding data for the
corresponding currency, so we call it with the <to_currency>
in order to get the
correct rounding data for our converted_amount
before formatting with the current
system locale.
The system_locale
function
provides the locale data for the POSIX locale of the running Python process.
linearmoney provides a threadsafe locale
function that returns the locale data for the provided language-region combination, and
it allows overriding the individual properties of the returned data, so it can be
used to create custom currency formats and is very useful in modern distributed systems
and web applications where the executing environment is often not in the same
locale as the user of the application, but in the case of a local application
such as our currency
converter script, we usually want to just use the locale of the running system without
needing to provide any configuration or other data to determine the correct locale.
linearmoney provides the system_locale
helper function for this exact use-case, so
instead of writing code to read the system locale with the stdlib or through some other
means and then passing that into the locale
function to get a
LocaleData
instance usable by the
library, we just call system_locale
, and we're good to go.
At this point, we should have the following script:
#!/usr/bin/python
"""Interactive commandline script for converting currencies.
Converts <amount> of <from_currency> to <to_currency>
Usage: [-h|--help] <amount> <from_currency> <to_currency>
"""
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
import time
import urllib.request
import json
import linearmoney as lm
def request_rates() -> dict:
"""Request the latest rates from theforexapi.com and
return them as a dict."""
print("Fetching latest forex rates from theforexapi.com...")
print("Waiting 2 seconds to comply with api rate limits...")
time.sleep(2) # Respect API rate limits.
url = "https://theforexapi.com/api/latest"
with urllib.request.urlopen(url) as response:
rate_dict = json.loads(response.read().decode("utf-8"))
return rate_dict
forex_vector = lm.vector.forex(request_rates())
currency_space = lm.vector.space(forex_vector)
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split() # [<amount>, <from_currency>, <to_currency>]
asset_vector = lm.vector.asset(raw_args[0], raw_args[1], currency_space)
converted_value = lm.vector.evaluate(
asset_vector, raw_args[2], forex_vector
)
to_currency = lm.data.currency(raw_args[2])
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
If we run this with python converter.py
, then we should see a couple of print
statements about fetching forex rates and then the prompt
Currency Converter-->
should come up.
If we enter something like 10 usd jpy
into this prompt and press enter, then
we should see the converted and formatted amount printed out.
Assuming the above is working correctly, we now have a working commandline currency
converter, but there is a problem; we have no error handling.
If we enter something like: "bad argument values"
, then our app crashes with a
weird exception about ARGUMENT
not being a part of currency space.
Since this is a REPL-like commandline app, we don't want it to crash when we get an exception, and ideally, we would also have some helpful feedback if the user provides invalid input.
Enter the standard library's
argparse
module.
Argparse
Python's argparse
module can be overwhelming if you've never used it
before, so you don't need to understand all of the code in this section.
All of the relevant objects and methods will be explained.
To start with, we'll import the argparse
module and create an ArgumentParser
:
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
import argparse
import time
import urllib.request
import json
import linearmoney as lm
forex_vector = lm.vector.forex(request_rates())
currency_space = lm.vector.space(forex_vector)
parser = argparse.ArgumentParser(
prog="Currency Converter-->",
description="Convert <amount> of <from_currency> to <to_currency>",
epilog="Use ctrl+c to exit/quit.",
)
The ArgumentParser
instance will be used to read the user input and validate it
as arguments while providing a standard help/man page for our commandline app.
The prog
argument is what will be displayed in the help/man page as the invocation
for the usage examples. We set it to the text of our custom prompt to make the
examples in the generated help match the interactive prompt.
The description
is like the first line of a Python docstring and gives a basic
summary of the app.
The epilog
is an optional string that will be displayed after the rest of the generated
help/man page. Since we don't have an explicit exit command that will be
documented, we give an explanation of how to exit the application here.
Parsing Args
Now that we have an ArgumentParser
instance, we can define our three arguments:
parser = argparse.ArgumentParser(
prog="Currency Converter-->",
description="Convert <amount> of <from_currency> to <to_currency>",
epilog="Use ctrl+c to exit/quit.",
)
parser.add_argument(
"amount",
metavar="<amount>",
help="""The monetary value to convert from one currency to another.
Must be convertable to Python's decimal.Decimal type. E.g. 100, 100.0,
1E+2, etc...""",
)
parser.add_argument(
"from_currency",
metavar="<from_currency>",
help=f"""The case-insensitive ISO 4217 aplphabetic currency code
of the <amount>. Accepted values: {currency_space.currencies}.""",
)
parser.add_argument(
"to_currency",
metavar="<to_currency>",
help=f"""The case-insensitive ISO 4217 alphabetic currency code
to convert the <amount> to. Accepted values: {currency_space.currencies}.""",
)
The ArgumentParser.add_argument
method adds the metadata for an argument with
optional help text to the parser.
The metavar
is how the argument will be displayed in usage examples in the
generated help/man page.
With the metadata for our commandline arguments added to the parser, we can now update our while loop to use the parser instead of a simple list for our arguments:
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split()
try:
args = parser.parse_args(raw_args)
except SystemExit:
# We want to show the error, but not kill the interactive prompt.
pass
else:
asset_vector = lm.vector.asset(args.amount, args.from_currency, currency_space)
converted_value = lm.vector.evaluate(
asset_vector, args.to_currency, forex_vector
)
to_currency = lm.data.currency(args.to_currency)
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
The ArgumentParser.parse_args
method normally parses the arguments passed to the
script on the commandline, but since we are parsing args in a loop, we can pass
in the list of strings from the user input directly to the parse_args
method, and
it will parse them as if they were passed directly to the script.
The standard behavior of a commandline app is to send an interrupt when
something goes wrong, which will cause the script to exit and dump some kind of
output, but we are running an interactive prompt, so we don't want our script to exit
when it runs into an error, so we catch the SystemExit
exception, which should be
thrown by the ArgumentParser
when it wants to indicate an exit interrupt should
be sent.
The members of args
are the named arguments we defined with each call to add_argument
previously, so we can access them by name.
At this point, our converter.py
should look like this:
#!/usr/bin/python
"""Interactive commandline script for converting currencies.
Converts <amount> of <from_currency> to <to_currency>
Usage: [-h|--help] <amount> <from_currency> <to_currency>
"""
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
import argparse
import time
import urllib.request
import json
import linearmoney as lm
def request_rates() -> dict:
"""Request the latest rates from theforexapi.com and
return them as a dict."""
print("Fetching latest forex rates from theforexapi.com...")
print("Waiting 2 seconds to comply with api rate limits...")
time.sleep(2) # Respect API rate limits.
url = "https://theforexapi.com/api/latest"
with urllib.request.urlopen(url) as response:
rate_dict = json.loads(response.read().decode("utf-8"))
return rate_dict
forex_vector = lm.vector.forex(request_rates())
currency_space = lm.vector.space(forex_vector)
parser = argparse.ArgumentParser(
prog="Currency Converter-->",
description="Convert <amount> of <from_currency> to <to_currency>",
epilog="Use ctrl+c to exit/quit.",
)
parser.add_argument(
"amount",
metavar="<amount>",
help="""The monetary value to convert from one currency to another.
Must be convertable to Python's decimal.Decimal type. E.g. 100, 100.0,
1E+2, etc...""",
)
parser.add_argument(
"from_currency",
metavar="<from_currency>",
help=f"""The case-insensitive ISO 4217 aplphabetic currency code
of the <amount>. Accepted values: {currency_space.currencies}.""",
)
parser.add_argument(
"to_currency",
metavar="<to_currency>",
help=f"""The case-insensitive ISO 4217 alphabetic currency code
to convert the <amount> to. Accepted values: {currency_space.currencies}.""",
)
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split()
try:
args = parser.parse_args(raw_args)
except SystemExit:
# We want to show the error, but not kill the interactive prompt.
pass
else:
asset_vector = lm.vector.asset(args.amount, args.from_currency, currency_space)
converted_value = lm.vector.evaluate(
asset_vector, args.to_currency, forex_vector
)
to_currency = lm.data.currency(args.to_currency)
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
If we try running it as is, we can access the generated help/man page with the -h
option
flag, and we see that our app does not exit, even though the generated help says
"show this help message and exit".
The -h
option flag prints the generated help text and sends the SystemExit
exception, so our generated help doesn't quite match the behavior of our
interactive prompt, but changing this is outside the scope of this
tutorial, so we'll just ignore that minor inconsistency.
Unfortunately, we still haven't provided any error handling, and if we give our
script our "bad argument values"
, it still crashes just like before.
Let's fix that.
Error handling
The add_argument
method can take an optional type
argument that is used when
parsing arguments to coerce the type of the argument from the str
that is provided
on the commandline to the appropriate runtime type. The value of the type
argument
should therefore be a callable that takes in a string and returns a value of the
desired type.
Let's start with the <amount>
argument:
# imports
import decimal
parser.add_argument(
"amount",
metavar="<amount>",
type=decimal.Decimal,
help="""The monetary value to convert from one currency to another.
Must be convertable to Python's decimal.Decimal type. E.g. 100, 100.0,
1E+2, etc...""",
)
We set the type
to decimal.Decimal
since that is a valid type for our asset
function's amount
argument that does not lose any information. E.g. we don't
want to use something like int
as that would destroy any sub-currency amounts we
provide.
If we make this change and then pass in our "bad argument values"
, we'll see that
the error has changed to decimal.InvalidOperation
. This is because instead of simply
exposing the value as-is in args
, the ArgumentParser.parse_args
method first calls
the decimal.Decimal
constructor with the value of the <amount>
argument, which
results in an exception since "bad"
can't be converted to a decimal.
What we want is to turn this exception into a SystemExit
if we can't convert the
<amount>
to a decimal, but we also want some kind of feedback to be printed out so
that the user knows that they provided an invalid value, so let's write a function
that we can provide as the type
argument for <amount>
:
def _error_decimal(amount: str) -> decimal.Decimal:
"""Wrapper function that validates input as a decimal-compatible value."""
try:
return decimal.Decimal(amount)
except decimal.InvalidOperation:
raise argparse.ArgumentTypeError(f"Invalid numeric string '{amount}'")
parser = argparse.ArgumentParser(
prog="Currency Converter-->",
description="Convert <amount> of <from_currency> to <to_currency>",
epilog="Use ctrl+c to exit/quit.",
)
parser.add_argument(
"amount",
metavar="<amount>",
type=_error_decimal,
help="""The monetary value to convert from one currency to another.
Must be convertable to Python's decimal.Decimal type. E.g. 100, 100.0,
1E+2, etc...""",
)
The _error_decimal
function raises argparse.ArgumentTypeError
, which will result
in the parse_args
method printing the error message we provide to the exception before
raising a SystemExit
exception, so we will get both proper error feedback to the
user, and we will not break our interactive prompt with an uncaught exception.
This takes care of the first argument, but what about the other two?
The <from_currency>
and <to_currency>
arguments are actually strings, but they need
to be valid currency codes in our currency_space
. Fortunately for us, the add_argument
method takes another optional argument choices
, which should be a
Sequence
of valid values for the argument.
We can use our currency_space
to define the valid values to avoid hard-coding any
currencies in our application:
parser.add_argument(
"from_currency",
metavar="<from_currency>",
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 aplphabetic currency code
of the <amount>. Accepted values: {currency_space.currencies}.""",
)
parser.add_argument(
"to_currency",
metavar="<to_currency>",
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 alphabetic currency code
to convert the <amount> to. Accepted values: {currency_space.currencies}.""",
)
The currencies
of our currency_space
are of course the valid currency codes that we have
forex rates for, so now, if we rerun with these changes, we'll see that passing in bad
input for the <from_currency>
or <to_currency>
arguments, such as "10 bad USD"
or
"10 USD bad"
gives an appropriate error message and doesn't crash our prompt.
However, we have another problem. Now if we provide a lower-case currency code, it
is considered invalid input, even if the currency code is supported by our app.
This didn't happen when we were using the values directly because linearmoney
forces currency codes to upper case whenever
they are used as strings for convenience, but the choices
option for add_argument
compares strings literally and never passes them into a linearmoney function if
they don't match. We can solve this by providing a type
function to the
<from_currency>
and <to_currency>
arguments that forces the argument to upper-case:
def _upper_code(currency_code: str) -> str:
"""Ensure the currency code is uppercase."""
return currency_code.upper()
parser.add_argument(
"from_currency",
metavar="<from_currency>",
type=_upper_code,
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 aplphabetic currency code
of the <amount>. Accepted values: {currency_space.currencies}.""",
)
parser.add_argument(
"to_currency",
metavar="<to_currency>",
type=_upper_code,
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 alphabetic currency code
to convert the <amount> to. Accepted values: {currency_space.currencies}.""",
)
At this point, our converter.py
script should be complete, and you should be able
to play around with it to convert different currencies and amounts as well as see
how it handles various invalid inputs.
Complete Script
#!/usr/bin/python
"""Interactive commandline script for converting currencies.
Converts <amount> of <from_currency> to <to_currency>
Usage: [-h|--help] <amount> <from_currency> <to_currency>
"""
if __name__ != "__main__":
raise ImportError("This is a standalone script and should not be imported.")
import decimal
import argparse
import time
import urllib.request
import json
import linearmoney as lm
def request_rates() -> dict:
"""Request the latest rates from theforexapi.com and
return them as a dict."""
print("Fetching latest forex rates from theforexapi.com...")
print("Waiting 2 seconds to comply with api rate limits...")
time.sleep(2) # Respect API rate limits.
url = "https://theforexapi.com/api/latest"
with urllib.request.urlopen(url) as response:
rate_dict = json.loads(response.read().decode("utf-8"))
return rate_dict
forex_vector = lm.vector.forex(request_rates())
currency_space = lm.vector.space(forex_vector)
def _upper_code(currency_code: str) -> str:
"""Ensure the currency code is uppercase."""
return currency_code.upper()
def _error_decimal(amount: str) -> decimal.Decimal:
"""Wrapper function that validates input as a decimal-compatible value."""
try:
return decimal.Decimal(amount)
except decimal.InvalidOperation:
raise argparse.ArgumentTypeError(f"Invalid numeric string '{amount}'")
parser = argparse.ArgumentParser(
prog="Currency Converter-->",
description="Convert <amount> of <from_currency> to <to_currency>",
epilog="Use ctrl+c to exit/quit.",
)
parser.add_argument(
"amount",
metavar="<amount>",
type=_error_decimal,
help="""The monetary value to convert from one currency to another.
Must be convertable to Python's decimal.Decimal type. E.g. 100, 100.0,
1E+2, etc...""",
)
parser.add_argument(
"from_currency",
metavar="<from_currency>",
type=_upper_code,
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 aplphabetic currency code
of the <amount>. Accepted values: {currency_space.currencies}.""",
)
parser.add_argument(
"to_currency",
metavar="<to_currency>",
type=_upper_code,
choices=currency_space.currencies,
help=f"""The case-insensitive ISO 4217 alphabetic currency code
to convert the <amount> to. Accepted values: {currency_space.currencies}.""",
)
while True:
readline = input("Currency Converter--> ")
raw_args = readline.split()
try:
args = parser.parse_args(raw_args)
except SystemExit:
# We want to show the error, but not kill the interactive prompt.
pass
else:
asset_vector = lm.vector.asset(args.amount, args.from_currency, currency_space)
converted_value = lm.vector.evaluate(
asset_vector, args.to_currency, forex_vector
)
to_currency = lm.data.currency(args.to_currency)
rounded_value = lm.scalar.roundas(converted_value, to_currency)
result = lm.scalar.l10n(rounded_value, to_currency, lm.data.system_locale())
print(result)
Conclusion
In this tutorial, we learned how to use linearmoney to create forex and asset vectors, evaluate/convert monetary amounts as a specific currency, and format a numeric value using the local currency format.
These functions can do a lot more than this though. To get a better idea of how to
integrate linearmoney with an application, take a look at the api documentation for the
l10n
,
roundas
,
currency
, and
locale
functions and change the
output to display the currency in the international format, as a cash value, or using
a custom format of your own creation.