From ab9f42382fe4980358faea370a828b6b2968b675 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Sat, 25 Apr 2026 08:20:55 +0100 Subject: replace match-case with dict lookup in sort function --- main.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index 8d6357a..b81021c 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,13 @@ from geopy.location import Location ENDPOINT = "https://www.fuel-finder.service.gov.uk/internal/v1.0.2/csv/get-latest-fuel-prices-csv" +SORT_KV = { + "e10": "e10_price", + "e5": "e5_price", + "b7s": "diesel_price", + "distance": "distance", +} + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() @@ -63,20 +70,9 @@ def filter_df(dframe, arguments, loc): return near_stations -def sort_list_of_stations(stations_list, arguments): - match arguments.sort: - case "e10": - sort_by = "e10_price" - return sorted(stations_list, key=lambda d: d[sort_by]) - case "e5": - sort_by = "e5_price" - return sorted(stations_list, key=lambda d: d[sort_by]) - case "b7s": - sort_by = "diesel_price" - return sorted(stations_list, key=lambda d: d[sort_by]) - case "distance": - sort_by = "distance" - return sorted(stations_list, key=lambda d: d[sort_by]) +def sort_stations(stations: list[dict], sort: str) -> list[dict]: + sort_key = SORT_KV.get(sort) + return sorted(stations, key=lambda d: d[sort_key]) def output_stations(stations): @@ -101,7 +97,7 @@ def main(): df_filtered = filter_df(df_processed, args, location) - sorted_stations_list = sort_list_of_stations(df_filtered, args) + sorted_stations_list = sort_stations(df_filtered, args.sort) output_stations(sorted_stations_list) -- cgit v1.2.3 From d787c2d720c1cd732ca7efcece93588504eab3a6 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Sat, 25 Apr 2026 14:24:43 +0100 Subject: tabulate stations output in output_stations() --- main.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index b81021c..f092eeb 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ import requests from geopy.distance import geodesic from geopy.geocoders import Nominatim from geopy.location import Location +from tabulate import tabulate ENDPOINT = "https://www.fuel-finder.service.gov.uk/internal/v1.0.2/csv/get-latest-fuel-prices-csv" @@ -76,14 +77,19 @@ def sort_stations(stations: list[dict], sort: str) -> list[dict]: def output_stations(stations): - for number, row in enumerate(stations): - output = dedent(f""" - {number + 1}. {row["station_name"]} - Distance: {row["distance"]} miles - E5 Price: £{row["e5_price"]:.2f}/L - E10 Price: £{row["e10_price"]:.2f}/L - B7S (Standard Diesel) Price: £{row["diesel_price"]:.2f}/L""") - print(output) + print( + tabulate( + stations, + headers={ + "station_name": "Station Name", + "distance": "Distance (miles)", + "e5_price": "E5 (£/L)", + "e10_price": "E10 (£/L)", + "diesel_price": "B7S (£/L)", + }, + floatfmt=".2f", + ) + ) def main(): -- cgit v1.2.3 From 79e8f8e76fd18a45a7f513b952261cc7d1a4fee9 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 09:37:15 +0100 Subject: improve sort function to handle N/A fuel prices --- main.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index f092eeb..ae842f4 100644 --- a/main.py +++ b/main.py @@ -67,13 +67,16 @@ def filter_df(dframe, arguments, loc): "e10_price": round(e10_price / 100, 2), "diesel_price": round(diesel_price / 100, 2), } - near_stations.append(station_dict) + na_dict = { + k: (v if v != 0.00 else "N/A") for (k, v) in station_dict.items() + } + near_stations.append(na_dict) return near_stations def sort_stations(stations: list[dict], sort: str) -> list[dict]: sort_key = SORT_KV.get(sort) - return sorted(stations, key=lambda d: d[sort_key]) + return sorted(stations, key=lambda d: d[sort_key] if d[sort_key] != "N/A" else 999) def output_stations(stations): -- cgit v1.2.3 From 4e0035ea359a22d1d395ae731c0ae3aa2ec921a3 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 19:27:17 +0100 Subject: add type hints and error handling to get_latest_data() --- main.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index ae842f4..d601c6b 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import argparse import sys from io import StringIO from textwrap import dedent +from typing import Tuple import pandas as pd import requests @@ -37,8 +38,12 @@ def get_location(address: str) -> tuple[float, float]: return (result.latitude, result.longitude) -def get_latest_data(): - response = requests.get(ENDPOINT) +def get_latest_data() -> Tuple[pd.DataFrame, str]: + try: + response = requests.get(ENDPOINT) + response.raise_for_status() + except Exception as e: + raise e return pd.read_csv(StringIO(response.text)), response.headers.get("Last-Modified") -- cgit v1.2.3 From c23d6575e9a020681a11079f8af4aae397398d17 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 19:31:47 +0100 Subject: add user-agent header to request in get_latest_data() --- main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index d601c6b..88bb81f 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,10 @@ SORT_KV = { "distance": "distance", } +HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_4; en-US) AppleWebKit/533.45 (KHTML, like Gecko) Chrome/48.0.2094.221 Safari/602" +} + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() @@ -40,7 +44,7 @@ def get_location(address: str) -> tuple[float, float]: def get_latest_data() -> Tuple[pd.DataFrame, str]: try: - response = requests.get(ENDPOINT) + response = requests.get(ENDPOINT, headers=HEADERS, timeout=10) response.raise_for_status() except Exception as e: raise e -- cgit v1.2.3 From 69a2e8aa7bfe02b386b61fbcabcca2bc1c738df6 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 20:01:19 +0100 Subject: remove useless try-except block in get_latest_data() --- main.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index 88bb81f..24d7d44 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import argparse import sys from io import StringIO from textwrap import dedent -from typing import Tuple +from typing import Any, Dict, List, Optional, Tuple import pandas as pd import requests @@ -42,22 +42,21 @@ def get_location(address: str) -> tuple[float, float]: return (result.latitude, result.longitude) -def get_latest_data() -> Tuple[pd.DataFrame, str]: - try: - response = requests.get(ENDPOINT, headers=HEADERS, timeout=10) - response.raise_for_status() - except Exception as e: - raise e +def get_latest_data() -> Tuple[pd.DataFrame, Optional[str]]: + response = requests.get(ENDPOINT, headers=HEADERS, timeout=10) + response.raise_for_status() return pd.read_csv(StringIO(response.text)), response.headers.get("Last-Modified") -def process_data(dframe): +def process_data(dframe: pd.DataFrame) -> pd.DataFrame: price_cols = [c for c in dframe.columns if "fuel_price" in c] dframe[price_cols] = dframe[price_cols].fillna(0.0) return dframe.fillna("N/A") -def filter_df(dframe, arguments, loc): +def filter_df( + dframe: pd.DataFrame, arguments: argparse.Namespace, loc: Tuple[float, float] +) -> List[Dict[str, Any]]: near_stations = [] for station, latitude, longitude, e5_price, e10_price, diesel_price in zip( dframe["forecourts.trading_name"], @@ -88,7 +87,7 @@ def sort_stations(stations: list[dict], sort: str) -> list[dict]: return sorted(stations, key=lambda d: d[sort_key] if d[sort_key] != "N/A" else 999) -def output_stations(stations): +def output_stations(stations: List[Dict[str, Any]]) -> None: print( tabulate( stations, -- cgit v1.2.3 From 29adb1b78c766ce5739e4bf5f9dbab1c8b03bcb4 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 20:22:35 +0100 Subject: remove unused dedent import --- main.py | 1 - 1 file changed, 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index 24d7d44..1c129a3 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ import argparse import sys from io import StringIO -from textwrap import dedent from typing import Any, Dict, List, Optional, Tuple import pandas as pd -- cgit v1.2.3 From da7a2864b4e24ec3b474232316ac26c200983eb3 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:08:02 +0100 Subject: update data sanitation to not replace NaN with 0.00 --- main.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index 1c129a3..b14c256 100644 --- a/main.py +++ b/main.py @@ -47,12 +47,6 @@ def get_latest_data() -> Tuple[pd.DataFrame, Optional[str]]: return pd.read_csv(StringIO(response.text)), response.headers.get("Last-Modified") -def process_data(dframe: pd.DataFrame) -> pd.DataFrame: - price_cols = [c for c in dframe.columns if "fuel_price" in c] - dframe[price_cols] = dframe[price_cols].fillna(0.0) - return dframe.fillna("N/A") - - def filter_df( dframe: pd.DataFrame, arguments: argparse.Namespace, loc: Tuple[float, float] ) -> List[Dict[str, Any]]: @@ -70,14 +64,17 @@ def filter_df( station_dict = { "station_name": station, "distance": round(distance_from_current_location, 1), - "e5_price": round(e5_price / 100, 2), - "e10_price": round(e10_price / 100, 2), - "diesel_price": round(diesel_price / 100, 2), + "e5_price": round(e5_price / 100, 2) + if not pd.isna(e5_price) + else "N/A", + "e10_price": round(e10_price / 100, 2) + if not pd.isna(e10_price) + else "N/A", + "diesel_price": round(diesel_price / 100, 2) + if not pd.isna(diesel_price) + else "N/A", } - na_dict = { - k: (v if v != 0.00 else "N/A") for (k, v) in station_dict.items() - } - near_stations.append(na_dict) + near_stations.append(station_dict) return near_stations @@ -109,9 +106,7 @@ def main(): print(f"Last modified: {last_modified}") - df_processed = process_data(df) - - df_filtered = filter_df(df_processed, args, location) + df_filtered = filter_df(df, args, location) sorted_stations_list = sort_stations(df_filtered, args.sort) -- cgit v1.2.3 From 3f8871ca60caeed972b49c0d84d3ca1e99cfa34f Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:09:56 +0100 Subject: improve invalid sort arg message by adding choices --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index b14c256..b136290 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("-a", "--address", type=str, required=True) parser.add_argument("-r", "--radius", type=int, default=5) - parser.add_argument("-s", "--sort", type=str, default="e10") + parser.add_argument("-s", "--sort", type=str, default="e10", choices=SORT_KV.keys()) return parser.parse_args() -- cgit v1.2.3 From 220c14af6d1dedcafa6c3b69f69ed0ad8a869bcc Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:12:43 +0100 Subject: use built-in type annotation for tuple in get_latest_data() --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index b136290..31414c3 100644 --- a/main.py +++ b/main.py @@ -41,7 +41,7 @@ def get_location(address: str) -> tuple[float, float]: return (result.latitude, result.longitude) -def get_latest_data() -> Tuple[pd.DataFrame, Optional[str]]: +def get_latest_data() -> tuple[pd.DataFrame, Optional[str]]: response = requests.get(ENDPOINT, headers=HEADERS, timeout=10) response.raise_for_status() return pd.read_csv(StringIO(response.text)), response.headers.get("Last-Modified") -- cgit v1.2.3 From 7bf88ef767d0bb91597471bc642725b98187d4a2 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:17:06 +0100 Subject: add raise to get_location() and exit in main() --- main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index 31414c3..9b8ea36 100644 --- a/main.py +++ b/main.py @@ -36,8 +36,7 @@ def get_location(address: str) -> tuple[float, float]: geolocator = Nominatim(user_agent="FuelNearMe") result = geolocator.geocode(address) if not isinstance(result, Location): - print("[*] Failed to get location. Please check if the address is valid.") - sys.exit(1) + raise ValueError(f"Failed to get location from address: '{address}") return (result.latitude, result.longitude) @@ -101,7 +100,12 @@ def output_stations(stations: List[Dict[str, Any]]) -> None: def main(): args = parse_args() - location = get_location(args.address) + + try: + location = get_location(args.address) + except ValueError as e: + print(f"[*] {e}") + sys.exit(1) df, last_modified = get_latest_data() print(f"Last modified: {last_modified}") -- cgit v1.2.3 From 60dbfd6f4b8d715dede8b1114ae96b51cd90bd0f Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:19:45 +0100 Subject: return and exit in output_stations() if no stations found in radius --- main.py | 3 +++ 1 file changed, 3 insertions(+) (limited to 'main.py') diff --git a/main.py b/main.py index 9b8ea36..d1542c8 100644 --- a/main.py +++ b/main.py @@ -83,6 +83,9 @@ def sort_stations(stations: list[dict], sort: str) -> list[dict]: def output_stations(stations: List[Dict[str, Any]]) -> None: + if not stations: + print("[*] No stations found.") + return print( tabulate( stations, -- cgit v1.2.3 From 43f4236822b7de75c5b390137cd56c93616b5f52 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 21:22:08 +0100 Subject: change last modified to last updated --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index d1542c8..dd54d37 100644 --- a/main.py +++ b/main.py @@ -111,7 +111,7 @@ def main(): sys.exit(1) df, last_modified = get_latest_data() - print(f"Last modified: {last_modified}") + print(f"Last updated: {last_modified}") df_filtered = filter_df(df, args, location) -- cgit v1.2.3 From 016b835dcd90e7dd4f7b4534d15376b6f6bd3e85 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 22:02:40 +0100 Subject: fix typo in raise statement in get_location() --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index dd54d37..9d9217c 100644 --- a/main.py +++ b/main.py @@ -36,7 +36,7 @@ def get_location(address: str) -> tuple[float, float]: geolocator = Nominatim(user_agent="FuelNearMe") result = geolocator.geocode(address) if not isinstance(result, Location): - raise ValueError(f"Failed to get location from address: '{address}") + raise ValueError(f"Failed to get location from address: '{address}'") return (result.latitude, result.longitude) -- cgit v1.2.3 From f896458629f0ed7ac1d64775710980856d8a7c64 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 22:04:49 +0100 Subject: replace .get() with direct indexing in sort_stations() --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index 9d9217c..b6c245a 100644 --- a/main.py +++ b/main.py @@ -78,7 +78,7 @@ def filter_df( def sort_stations(stations: list[dict], sort: str) -> list[dict]: - sort_key = SORT_KV.get(sort) + sort_key = SORT_KV[sort] return sorted(stations, key=lambda d: d[sort_key] if d[sort_key] != "N/A" else 999) -- cgit v1.2.3 From fae55f02b2b4739fdbbee7c660927639781a3fb3 Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 22:23:09 +0100 Subject: vectorise filter_df using haversine and bounding box pre-filter --- main.py | 70 +++++++++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 26 deletions(-) (limited to 'main.py') diff --git a/main.py b/main.py index b6c245a..f875fe6 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ import argparse +import math import sys from io import StringIO from typing import Any, Dict, List, Optional, Tuple +import numpy as np import pandas as pd import requests from geopy.distance import geodesic @@ -49,32 +51,48 @@ def get_latest_data() -> tuple[pd.DataFrame, Optional[str]]: def filter_df( dframe: pd.DataFrame, arguments: argparse.Namespace, loc: Tuple[float, float] ) -> List[Dict[str, Any]]: - near_stations = [] - for station, latitude, longitude, e5_price, e10_price, diesel_price in zip( - dframe["forecourts.trading_name"], - dframe["forecourts.location.latitude"], - dframe["forecourts.location.longitude"], - dframe["forecourts.fuel_price.E5"], - dframe["forecourts.fuel_price.E10"], - dframe["forecourts.fuel_price.B7S"], - ): - distance_from_current_location = geodesic((latitude, longitude), loc).miles - if distance_from_current_location < arguments.radius: - station_dict = { - "station_name": station, - "distance": round(distance_from_current_location, 1), - "e5_price": round(e5_price / 100, 2) - if not pd.isna(e5_price) - else "N/A", - "e10_price": round(e10_price / 100, 2) - if not pd.isna(e10_price) - else "N/A", - "diesel_price": round(diesel_price / 100, 2) - if not pd.isna(diesel_price) - else "N/A", - } - near_stations.append(station_dict) - return near_stations + + def bounding_box() -> pd.DataFrame: + lat, lon = loc + deg_lat = arguments.radius / 69.0 + deg_lon = arguments.radius / (69.0 * math.cos(math.radians(lat))) + return dframe[ + dframe["forecourts.location.latitude"].between(lat - deg_lat, lat + deg_lat) + & dframe["forecourts.location.longitude"].between( + lon - deg_lon, lon + deg_lon + ) + ] + + def haversine_miles(lat2: np.ndarray, lon2: np.ndarray) -> np.ndarray: + R = 3958.8 + lat1, lon1 = np.radians(loc[0]), np.radians(loc[1]) + lat2, lon2 = np.radians(lat2), np.radians(lon2) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 + return R * 2 * np.arcsin(np.sqrt(a)) + + def pence_to_pounds(col: pd.Series) -> pd.Series: + return (col / 100).round(2).where(col.notna(), other="N/A") + + df = bounding_box().copy() + + df["distance"] = haversine_miles( + df["forecourts.location.latitude"].to_numpy(), + df["forecourts.location.longitude"].to_numpy(), + ).round(1) + + df = df[df["distance"] < arguments.radius] + + df = df.assign( + e5_price=pence_to_pounds(df["forecourts.fuel_price.E5"]), + e10_price=pence_to_pounds(df["forecourts.fuel_price.E10"]), + diesel_price=pence_to_pounds(df["forecourts.fuel_price.B7S"]), + ) + + return df.rename(columns={"forecourts.trading_name": "station_name"})[ + ["station_name", "distance", "e5_price", "e10_price", "diesel_price"] + ].to_dict(orient="records") def sort_stations(stations: list[dict], sort: str) -> list[dict]: -- cgit v1.2.3 From 3e2678a05bee7b0db9e0415dc9c8c518379ed4af Mon Sep 17 00:00:00 2001 From: Alex Schofield Date: Mon, 27 Apr 2026 22:34:31 +0100 Subject: remove unused geopy import --- main.py | 1 - 1 file changed, 1 deletion(-) (limited to 'main.py') diff --git a/main.py b/main.py index f875fe6..16b0300 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd import requests -from geopy.distance import geodesic from geopy.geocoders import Nominatim from geopy.location import Location from tabulate import tabulate -- cgit v1.2.3