many changes

This commit is contained in:
Pablo Martin 2024-06-11 21:10:07 +02:00
parent 126ede37a4
commit 661941a65c
7 changed files with 102 additions and 21 deletions

View file

@ -1,3 +1,4 @@
import csv
import datetime import datetime
import random import random
@ -11,6 +12,18 @@ def test_get_rates_for_hardcoded_case_returns_expected_output():
""" """
Calling the CLI get-rates command to get the rates between USD, EUR and GBP Calling the CLI get-rates command to get the rates between USD, EUR and GBP
for 2024-01-01 to 2024-01-03 returns the expected records in a CSV. for 2024-01-01 to 2024-01-03 returns the expected records in a CSV.
ATTENTION!
This is a production-grade test. It will run against xe.com and requires you
to have a proper setup. It is not intended to be an automated or frequent
test, but rather be a helper.
Because of this, you can see that the test is mostly commented out. If you
want to run it, uncomment and use it only for what you need.
When commented, the test should always pass to not bother automated test
runs.
""" """
runner = CliRunner() runner = CliRunner()
@ -50,7 +63,12 @@ def test_get_rates_dry_run_always_returns_42_as_rates():
day=random.choice(range(1, 29)), day=random.choice(range(1, 29)),
).date() ).date()
some_random_currencies = random.choices(population=list(Currency), k=3) some_random_currencies = [
# Get the last 3 right characters, which are the actual
# currency code
str(some_currency)[-3:]
for some_currency in random.sample(population=list(Currency), k=3)
]
runner = CliRunner() runner = CliRunner()
@ -63,14 +81,7 @@ def test_get_rates_dry_run_always_returns_42_as_rates():
"--end-date", "--end-date",
(some_random_date + datetime.timedelta(days=3)).strftime("%Y-%m-%d"), (some_random_date + datetime.timedelta(days=3)).strftime("%Y-%m-%d"),
"--currencies", "--currencies",
",".join( ",".join(some_random_currencies),
[
# Get the last 3 right characters, which are the actual
# currency code
str(some_currency)[-3:]
for some_currency in some_random_currencies
]
),
"--output", "--output",
"test_output.csv", "test_output.csv",
"--dry-run", "--dry-run",
@ -79,6 +90,29 @@ def test_get_rates_dry_run_always_returns_42_as_rates():
assert run_result.exit_code == 0 assert run_result.exit_code == 0
# Write code here to read output file and compare it against expected with open("test_output.csv", newline="") as csv_file:
# output reader = csv.DictReader(csv_file)
assert False rows = list(reader)
# Ensure that the output contains the correct number of rows
expected_num_rows = (3 + 1) * len(
some_random_currencies
) # 3 days + 1 day = 4 dates for each currency
assert (
len(rows) == expected_num_rows
), f"Expected {expected_num_rows} rows, but got {len(rows)}"
# Check that all rows have the expected rate of 42 and the correct dates
for row in rows:
assert row["rate"] == "42", f"Expected rate to be 42, but got {row['rate']}"
assert row["rate_date"] in [
(some_random_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d")
for i in range(4)
], f"Unexpected rate_date {row['rate_date']}"
assert (
row["from_currency"] in some_random_currencies
), f"Unexpected from_currency {row['from_currency']}"
assert (
row["to_currency"] in some_random_currencies
), f"Unexpected to_currency {row['to_currency']}"

View file

@ -14,6 +14,7 @@ def test_handle_input_rates_works_with_full_correct_inputs():
end_date=datetime.datetime.now() - datetime.timedelta(days=1), end_date=datetime.datetime.now() - datetime.timedelta(days=1),
currencies="USD,EUR,GBP", currencies="USD,EUR,GBP",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.csv", output="test_output.csv",
) )
expected_result = { expected_result = {
@ -23,6 +24,7 @@ def test_handle_input_rates_works_with_full_correct_inputs():
), ),
"currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")}, "currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")},
"dry_run": False, "dry_run": False,
"ignore_warnings": True,
"output": pathlib.Path("test_output.csv"), "output": pathlib.Path("test_output.csv"),
} }
@ -38,6 +40,7 @@ def test_handle_input_rates_raises_with_bad_currency_code():
end_date=datetime.datetime.now() + datetime.timedelta(days=7), end_date=datetime.datetime.now() + datetime.timedelta(days=7),
currencies="not_a_currency,USD,not_this_either", currencies="not_a_currency,USD,not_this_either",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.csv", output="test_output.csv",
) )
@ -49,6 +52,7 @@ def test_handle_input_rates_raises_with_start_date_after_end_date():
end_date=datetime.datetime.now() - datetime.timedelta(days=7), end_date=datetime.datetime.now() - datetime.timedelta(days=7),
currencies="GBP,USD", currencies="GBP,USD",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.csv", output="test_output.csv",
) )
@ -60,6 +64,7 @@ def test_handle_input_rates_raises_with_output_different_than_csv():
end_date=datetime.datetime.now() + datetime.timedelta(days=7), end_date=datetime.datetime.now() + datetime.timedelta(days=7),
currencies="GBP,USD", currencies="GBP,USD",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.xlsx", output="test_output.xlsx",
) )
@ -70,6 +75,7 @@ def test_handle_input_rates_brings_future_end_date_to_today():
end_date=datetime.datetime.now() + datetime.timedelta(days=7), end_date=datetime.datetime.now() + datetime.timedelta(days=7),
currencies="USD,EUR,GBP", currencies="USD,EUR,GBP",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.csv", output="test_output.csv",
) )
@ -82,6 +88,7 @@ def test_handle_input_rates_start_and_end_date_equal_works_fine():
end_date=datetime.datetime.now(), end_date=datetime.datetime.now(),
currencies="USD,EUR,GBP", currencies="USD,EUR,GBP",
dry_run=False, dry_run=False,
ignore_warnings=True,
output="test_output.csv", output="test_output.csv",
) )
expected_result = { expected_result = {
@ -91,6 +98,7 @@ def test_handle_input_rates_start_and_end_date_equal_works_fine():
), ),
"currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")}, "currencies": {Currency("USD"), Currency("EUR"), Currency("GBP")},
"dry_run": False, "dry_run": False,
"ignore_warnings": True,
"output": pathlib.Path("test_output.csv"), "output": pathlib.Path("test_output.csv"),
} }

View file

@ -66,12 +66,14 @@ def xe_healthcheck():
"--currencies", default=",".join([]), show_default=True, type=click.STRING "--currencies", default=",".join([]), show_default=True, type=click.STRING
) )
@click.option("--dry-run", is_flag=True) @click.option("--dry-run", is_flag=True)
@click.option("--ignore-warnings", is_flag=True)
@click.option("--output", type=click.STRING, required=True) @click.option("--output", type=click.STRING, required=True)
def get_rates( def get_rates(
start_date: Union[str, datetime.datetime, datetime.date], start_date: Union[str, datetime.datetime, datetime.date],
end_date: Union[str, datetime.datetime, datetime.date], end_date: Union[str, datetime.datetime, datetime.date],
currencies: Union[None, str], currencies: Union[None, str],
dry_run: bool, dry_run: bool,
ignore_warnings: bool,
output: pathlib.Path, output: pathlib.Path,
): ):
get_rates_inputs = handle_get_rates_inputs( get_rates_inputs = handle_get_rates_inputs(
@ -79,6 +81,7 @@ def get_rates(
end_date=end_date, end_date=end_date,
currencies=currencies, currencies=currencies,
dry_run=dry_run, dry_run=dry_run,
ignore_warnings=ignore_warnings,
output=output, output=output,
) )
logger.info("Starting get-rates process.") logger.info("Starting get-rates process.")

View file

@ -16,6 +16,7 @@ def handle_get_rates_inputs(
end_date: Union[datetime.datetime, datetime.date], end_date: Union[datetime.datetime, datetime.date],
currencies: Union[None, str], currencies: Union[None, str],
dry_run: bool, dry_run: bool,
ignore_warnings: bool,
output: Union[str, pathlib.Path], output: Union[str, pathlib.Path],
): ):
logger.info("Handling inputs.") logger.info("Handling inputs.")
@ -31,7 +32,8 @@ def handle_get_rates_inputs(
tmp = {Currency(currency_code) for currency_code in currencies} tmp = {Currency(currency_code) for currency_code in currencies}
currencies = tmp currencies = tmp
if currencies is None: if currencies is None or currencies == "":
logger.info("No currency list passed. Running for default currencies.")
currencies = DEFAULT_CURRENCIES currencies = DEFAULT_CURRENCIES
# The Path constructor is idempotent, so this works equally fine if output # The Path constructor is idempotent, so this works equally fine if output
@ -44,6 +46,7 @@ def handle_get_rates_inputs(
"date_range": date_range, "date_range": date_range,
"currencies": currencies, "currencies": currencies,
"dry_run": dry_run, "dry_run": dry_run,
"ignore_warnings": ignore_warnings,
"output": output, "output": output,
} }

View file

@ -56,16 +56,23 @@ def run_get_rates(
date_range: DateRange, date_range: DateRange,
currencies: List[Currency], currencies: List[Currency],
dry_run: bool, dry_run: bool,
ignore_warnings: bool,
output: pathlib.Path, output: pathlib.Path,
) -> None: ) -> None:
logger.info("Getting rates") logger.info("Getting rates")
process_state = GetRatesProcessState(output=output, dry_run=dry_run) process_state = GetRatesProcessState(
output=output, dry_run=dry_run, ignore_warnings=ignore_warnings
)
rates = obtain_rates_from_source( rates = obtain_rates_from_source(
process_state, date_range=date_range, currencies=currencies process_state,
date_range=date_range,
currencies=currencies,
) )
logger.info("Rates obtained.")
write_rates_to_output(process_state, rates) write_rates_to_output(process_state, rates)
logger.info("Rates written to output.")
def obtain_rates_from_source( def obtain_rates_from_source(
@ -77,6 +84,21 @@ def obtain_rates_from_source(
date_range=date_range, currencies=currencies date_range=date_range, currencies=currencies
) )
large_api_call_planned = (
rates_fetcher.is_production_grade and len(currency_and_date_combinations) > 100
)
if large_api_call_planned and not process_state.ignore_warnings:
user_confirmation_string = "i understand"
user_response = input(
f"WARNING: you are about to execute a large call {len(currency_and_date_combinations)} to a metered API. Type '{user_confirmation_string}' to move forward."
)
if user_response != user_confirmation_string:
raise Exception("Execution aborted.")
logger.debug(
f"We are looking for the following rate combinations: {currency_and_date_combinations}"
)
rates = ExchangeRates() rates = ExchangeRates()
for combination in currency_and_date_combinations: for combination in currency_and_date_combinations:
try: try:
@ -97,13 +119,15 @@ def obtain_rates_from_source(
def write_rates_to_output(process_state, rates): def write_rates_to_output(process_state, rates):
rates_writer = process_state.get_writer() rates_writer = process_state.get_writer()
logger.info("Attempting writing rates to output.")
rates_writer.write_rates(rates) rates_writer.write_rates(rates)
class GetRatesProcessState: class GetRatesProcessState:
def __init__(self, output: str, dry_run: bool) -> None: def __init__(self, output: str, dry_run: bool, ignore_warnings: bool) -> None:
self.writer = self._select_writer(output) self.writer = self._select_writer(output)
self.fetcher = self._select_fetcher(dry_run) self.fetcher = self._select_fetcher(dry_run)
self.ignore_warnings = ignore_warnings
@staticmethod @staticmethod
def _select_writer(output: str) -> CSVRateWriter: def _select_writer(output: str) -> CSVRateWriter:
@ -115,12 +139,14 @@ class GetRatesProcessState:
raise ValueError(f"Don't know how to handle passed output: {output}") raise ValueError(f"Don't know how to handle passed output: {output}")
@staticmethod @staticmethod
def _select_fetcher(dry_run: bool) -> str: def _select_fetcher(dry_run: bool) -> RateFetcher:
if dry_run: if dry_run:
return MockRateFetcher logger.info("Dry-run activated. Running against MockRateFetcher.")
return MockRateFetcher()
if not dry_run: if not dry_run:
return XERateFetcher logger.info("Real run active. Running against XE.com's API.")
return XERateFetcher()
def get_fetcher(self) -> RateFetcher: def get_fetcher(self) -> RateFetcher:
return self.fetcher return self.fetcher

View file

@ -10,6 +10,9 @@ from xexe.exchange_rates import ExchangeRate
class RateFetcher(ABC): class RateFetcher(ABC):
is_production_grade = False
@abstractmethod @abstractmethod
def fetch_rate( def fetch_rate(
self, from_currency: Currency, to_currency: Currency, rate_date: datetime.date self, from_currency: Currency, to_currency: Currency, rate_date: datetime.date
@ -19,6 +22,8 @@ class RateFetcher(ABC):
class MockRateFetcher(RateFetcher): class MockRateFetcher(RateFetcher):
is_production_grade = False
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -36,6 +41,8 @@ class MockRateFetcher(RateFetcher):
class XERateFetcher(RateFetcher): class XERateFetcher(RateFetcher):
is_production_grade = True
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.xe_client = XecdClient( self.xe_client = XecdClient(

View file

@ -18,8 +18,8 @@ class CSVRateWriter(RateWriter):
self.output_file_path = output_file_path self.output_file_path = output_file_path
def write_rates(self, rates: ExchangeRates) -> None: def write_rates(self, rates: ExchangeRates) -> None:
with open(self.output_file_path, mode="w", newline="") as csvfile: with open(self.output_file_path, mode="w") as csv_file:
csv_writer = csv.writer(csvfile) csv_writer = csv.writer(csv_file)
csv_writer.writerow(["from_currency", "to_currency", "rate", "rate_date"]) csv_writer.writerow(["from_currency", "to_currency", "rate", "rate_date"])
# Write the exchange rate data # Write the exchange rate data