diff --git a/agenda/stock_market.py b/agenda/stock_market.py index 60b8c7a..696fcda 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 +import pandas # type: ignore from . import utils diff --git a/agenda/trip_schengen.py b/agenda/trip_schengen.py index 926d022..6e34044 100644 --- a/agenda/trip_schengen.py +++ b/agenda/trip_schengen.py @@ -224,6 +224,28 @@ 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, diff --git a/agenda/types.py b/agenda/types.py index 6ac09cb..d6041ed 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: list[SchengenStay] + stays_in_period: SchengenStay next_reset_date: typing.Optional[date] # When the 180-day window resets @property diff --git a/validate_yaml.py b/validate_yaml.py index db2c6b0..f49bf49 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, cast +from typing import Tuple, TypeVar import yaml -from geopy.distance import distance # type: ignore[import-untyped] +from geopy.distance import distance from rich.pretty import pprint import agenda @@ -23,8 +23,6 @@ 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.""" @@ -36,15 +34,11 @@ def check_currency(item: agenda.types.StrDict) -> None: sys.exit(-1) -def get_coords(item: agenda.types.StrDict) -> LatLon | None: - """Return latitude/longitude tuple when present.""" +def get_coords(item): if "latitude" in item and "longitude" in item: - latitude = item["latitude"] - longitude = item["longitude"] - assert isinstance(latitude, (int, float)) - assert isinstance(longitude, (int, float)) - return (float(latitude), float(longitude)) - return None + return (item["latitude"], item["longitude"]) + else: + return None T = TypeVar("T") @@ -55,33 +49,12 @@ 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 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 + return distance(a, b).km def check_trips() -> None: @@ -94,7 +67,6 @@ 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')} - " @@ -114,69 +86,12 @@ def check_trips() -> None: for trip in trip_list: if not trip.accommodation or not trip.conferences: continue - 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: + 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: continue - - 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 + dist = distance_km(conference_coords[0], accommodation_coords[0]) + assert dist < 5.0 coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir) print(len(coords), "coords") @@ -217,13 +132,14 @@ def check_flights(airlines: set[str]) -> None: ) -def normalize_datetime(dt_value: date | datetime) -> datetime: +def normalize_datetime(dt_value): """Convert date or datetime to datetime for comparison, removing timezone info.""" - if isinstance(dt_value, datetime): - return dt_value.replace(tzinfo=None) - if isinstance(dt_value, date): + if isinstance(dt_value, date) and not isinstance(dt_value, datetime): return datetime.combine(dt_value, datetime.min.time()) - raise TypeError(f"Unsupported datetime value type: {type(dt_value)}") + elif isinstance(dt_value, datetime): + # Remove timezone info to allow comparison between naive and aware datetimes + return dt_value.replace(tzinfo=None) + return dt_value def check_trains() -> None: @@ -235,7 +151,6 @@ 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', '')}" @@ -269,7 +184,6 @@ 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')}" @@ -323,7 +237,6 @@ 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', '')})" @@ -369,7 +282,6 @@ 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', '')}"