From 4364c95f258907566a491fc031abf61d5c15d3c6 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 19:14:27 +0000 Subject: [PATCH 1/5] Move accommodation max distance to config --- validate_yaml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/validate_yaml.py b/validate_yaml.py index f49bf49..4497bd8 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -91,7 +91,7 @@ def check_trips() -> None: if len(accommodation_coords) != 1 or len(conference_coords) != 1: continue dist = distance_km(conference_coords[0], accommodation_coords[0]) - assert dist < 5.0 + assert dist < config.ACCOMODATION_MAX_DISTANCE_KM coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir) print(len(coords), "coords") From a7ec33c500d81a5403ffeba58b89117be27ea7fa Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 19:29:42 +0000 Subject: [PATCH 2/5] warn if hotel/accommodation coords to far apart --- validate_yaml.py | 130 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 21 deletions(-) diff --git a/validate_yaml.py b/validate_yaml.py index 4497bd8..db2c6b0 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -5,10 +5,10 @@ import os import sys import typing from datetime import date, datetime, timedelta -from typing import Tuple, TypeVar +from typing import Tuple, TypeVar, cast import yaml -from geopy.distance import distance +from geopy.distance import distance # type: ignore[import-untyped] from rich.pretty import pprint import agenda @@ -23,6 +23,8 @@ data_dir = config.PERSONAL_DATA currencies = set(config.CURRENCIES + ["GBP"]) +LatLon = Tuple[float, float] + def check_currency(item: agenda.types.StrDict) -> None: """Throw error if currency is not in config.""" @@ -34,11 +36,15 @@ def check_currency(item: agenda.types.StrDict) -> None: sys.exit(-1) -def get_coords(item): +def get_coords(item: agenda.types.StrDict) -> LatLon | None: + """Return latitude/longitude tuple when present.""" if "latitude" in item and "longitude" in item: - return (item["latitude"], item["longitude"]) - else: - return None + latitude = item["latitude"] + longitude = item["longitude"] + assert isinstance(latitude, (int, float)) + assert isinstance(longitude, (int, float)) + return (float(latitude), float(longitude)) + return None T = TypeVar("T") @@ -49,12 +55,33 @@ def remove_nones(items: list[T | None]) -> list[T]: return [item for item in items if item is not None] -LatLon = Tuple[float, float] - - def distance_km(a: LatLon, b: LatLon) -> float: """Return the great-circle distance between two (lat, lon) points in km.""" - return distance(a, b).km + return cast(float, distance(a, b).km) + + +def parse_datetime_value(value: typing.Any) -> datetime | None: + """Return naive datetime for supported input types.""" + if value is None: + return None + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"Invalid ISO datetime string: {value}") from exc + return parsed.replace(tzinfo=None) + if isinstance(value, datetime): + return value.replace(tzinfo=None) + if isinstance(value, date): + return datetime.combine(value, datetime.min.time()) + raise TypeError(f"Unsupported datetime value type: {type(value)}") + + +def ranges_overlap( + start_a: datetime, end_a: datetime, start_b: datetime, end_b: datetime +) -> bool: + """Return True when two datetime ranges overlap.""" + return start_a < end_b and start_b < end_a def check_trips() -> None: @@ -67,6 +94,7 @@ def check_trips() -> None: for trip_data in trips_data: current_trip = normalize_datetime(trip_data["trip"]) if prev_trip and current_trip < prev_trip: + assert prev_trip_data is not None print("Out of order trip found:") print( f" Previous: {prev_trip_data.get('trip')} - " @@ -86,12 +114,69 @@ def check_trips() -> None: for trip in trip_list: if not trip.accommodation or not trip.conferences: continue - accommodation_coords = remove_nones([get_coords(a) for a in trip.accommodation]) - conference_coords = remove_nones([get_coords(c) for c in trip.conferences]) - if len(accommodation_coords) != 1 or len(conference_coords) != 1: + accommodation_entries: list[ + tuple[agenda.types.StrDict, LatLon, datetime, datetime] + ] = [] + for accommodation in trip.accommodation: + accommodation_coords = get_coords(accommodation) + if accommodation_coords is None: + continue + start_dt = parse_datetime_value(accommodation.get("from")) + end_dt = parse_datetime_value(accommodation.get("to")) + if start_dt is None or end_dt is None: + continue + accommodation_entries.append( + (accommodation, accommodation_coords, start_dt, end_dt) + ) + + if not accommodation_entries: continue - dist = distance_km(conference_coords[0], accommodation_coords[0]) - assert dist < config.ACCOMODATION_MAX_DISTANCE_KM + + for conference in trip.conferences: + if conference.get("online"): + continue + conference_coords = get_coords(conference) + if conference_coords is None: + continue + start_dt = parse_datetime_value(conference.get("start")) + end_value = conference.get("end") or conference.get("start") + end_dt = parse_datetime_value(end_value) + if start_dt is None or end_dt is None: + continue + + conference_country = ( + str(conference.get("country")).lower() + if conference.get("country") + else None + ) + overlapping_distances = [] + for ( + accommodation_item, + accommodation_coords, + accommodation_start, + accommodation_end, + ) in accommodation_entries: + accommodation_country = ( + str(accommodation_item.get("country")).lower() + if accommodation_item.get("country") + else None + ) + if ( + conference_country + and accommodation_country + and accommodation_country != conference_country + ): + continue + if not ranges_overlap( + accommodation_start, accommodation_end, start_dt, end_dt + ): + continue + overlapping_distances.append( + distance_km(conference_coords, accommodation_coords) + ) + if not overlapping_distances: + continue + assert min(overlapping_distances) < config.ACCOMODATION_MAX_DISTANCE_KM coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir) print(len(coords), "coords") @@ -132,14 +217,13 @@ def check_flights(airlines: set[str]) -> None: ) -def normalize_datetime(dt_value): +def normalize_datetime(dt_value: date | datetime) -> datetime: """Convert date or datetime to datetime for comparison, removing timezone info.""" - if isinstance(dt_value, date) and not isinstance(dt_value, datetime): - return datetime.combine(dt_value, datetime.min.time()) - elif isinstance(dt_value, datetime): - # Remove timezone info to allow comparison between naive and aware datetimes + if isinstance(dt_value, datetime): return dt_value.replace(tzinfo=None) - return dt_value + if isinstance(dt_value, date): + return datetime.combine(dt_value, datetime.min.time()) + raise TypeError(f"Unsupported datetime value type: {type(dt_value)}") def check_trains() -> None: @@ -151,6 +235,7 @@ def check_trains() -> None: for train in trains: current_depart = normalize_datetime(train["depart"]) if prev_depart and current_depart < prev_depart: + assert prev_train is not None print(f"Out of order train found:") print( f" Previous: {prev_train.get('depart')} {prev_train.get('from', '')} -> {prev_train.get('to', '')}" @@ -184,6 +269,7 @@ def check_conferences() -> None: current_start = normalize_datetime(conf_data["start"]) if prev_start and current_start < prev_start: + assert prev_conf_data is not None print(f"Out of order conference found:") print( f" Previous: {prev_conf_data.get('start')} - {prev_conf_data.get('name', 'No name')}" @@ -237,6 +323,7 @@ def check_accommodation() -> None: current_from = normalize_datetime(stay["from"]) if prev_from and current_from < prev_from: + assert prev_stay is not None print(f"Out of order accommodation found:") print( f" Previous: {prev_stay.get('from')} - {prev_stay.get('name', 'No name')} ({prev_stay.get('location', '')})" @@ -282,6 +369,7 @@ def check_ferries() -> None: for ferry in ferries: current_depart = normalize_datetime(ferry["depart"]) if prev_depart and current_depart < prev_depart: + assert prev_ferry is not None print(f"Out of order ferry found:") print( f" Previous: {prev_ferry.get('depart')} {prev_ferry.get('from', '')} -> {prev_ferry.get('to', '')}" From 83e1406ab43143bf9546e4927bb24e6f8d8d06c6 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 19:30:29 +0000 Subject: [PATCH 3/5] Fix type error. --- agenda/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agenda/types.py b/agenda/types.py index d6041ed..6ac09cb 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -83,7 +83,7 @@ class SchengenCalculation: days_remaining: int is_compliant: bool current_180_day_period: tuple[date, date] # (start, end) - stays_in_period: SchengenStay + stays_in_period: list[SchengenStay] next_reset_date: typing.Optional[date] # When the 180-day window resets @property From e82ca9d9e9b9d865dc00985fb7fd64ecfe1ab818 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 19:30:57 +0000 Subject: [PATCH 4/5] The pandas module has type info. --- agenda/stock_market.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agenda/stock_market.py b/agenda/stock_market.py index 696fcda..60b8c7a 100644 --- a/agenda/stock_market.py +++ b/agenda/stock_market.py @@ -4,7 +4,7 @@ from datetime import timedelta, timezone import dateutil.tz import exchange_calendars # type: ignore -import pandas # type: ignore +import pandas from . import utils From 8a9126b72926cee691b25e18722cf2eb615c6d66 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 19:31:08 +0000 Subject: [PATCH 5/5] Remove unused code. --- agenda/trip_schengen.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/agenda/trip_schengen.py b/agenda/trip_schengen.py index 6e34044..926d022 100644 --- a/agenda/trip_schengen.py +++ b/agenda/trip_schengen.py @@ -224,28 +224,6 @@ def export_schengen_data_for_external_calculator( return export_data -def add_schengen_info_to_events(events: list, data_dir: str | None = None) -> list: - """Add Schengen compliance info to event list.""" - if data_dir is None: - data_dir = flask.current_app.config["PERSONAL_DATA"] - - # Load all trips to get travel context - trip_list = trip.build_trip_list(data_dir) - all_travel_items = [] - for trip_obj in trip_list: - all_travel_items.extend(trip_obj.travel) - - # Add Schengen status to each event - for event in events: - if hasattr(event, "date"): - calculation = calculate_schengen_time(all_travel_items, event.date) - event.schengen_days_used = calculation.total_days_used - event.schengen_compliant = calculation.is_compliant - event.schengen_days_remaining = calculation.days_remaining - - return events - - # Integration with existing trip.py functions def enhanced_build_trip_list( data_dir: str | None = None,