From 107295575e3a7c37911ae192baf426b0003975a4 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 8 Dec 2025 01:37:10 +0100 Subject: Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized functions. --- .../Services/Providers/RenfeTransitProvider.cs | 9 +- src/frontend/app/AppContext.tsx | 13 +- src/frontend/app/components/LineIcon.css | 10 + .../Stops/ConsolidatedCirculationCard.css | 1 - .../Stops/ConsolidatedCirculationCard.tsx | 96 ++- .../Stops/ConsolidatedCirculationList.tsx | 3 + src/frontend/app/config/RegionConfig.ts | 25 +- src/frontend/app/routes/home.tsx | 10 +- src/frontend/app/routes/stops-$id.tsx | 5 +- src/gtfs_perstop_report/.gitignore | 13 + src/gtfs_perstop_report/pyproject.toml | 27 + src/gtfs_perstop_report/src/__init__.py | 0 src/gtfs_perstop_report/src/common.py | 67 +++ src/gtfs_perstop_report/src/download.py | 130 ++++ src/gtfs_perstop_report/src/logger.py | 54 ++ src/gtfs_perstop_report/src/proto/__init__.py | 1 + .../src/proto/stop_schedule_pb2.py | 32 + .../src/proto/stop_schedule_pb2.pyi | 69 +++ src/gtfs_perstop_report/src/providers.py | 137 +++++ src/gtfs_perstop_report/src/report_writer.py | 145 +++++ src/gtfs_perstop_report/src/routes.py | 43 ++ src/gtfs_perstop_report/src/services.py | 113 ++++ src/gtfs_perstop_report/src/shapes.py | 88 +++ src/gtfs_perstop_report/src/stop_schedule_pb2.py | 41 ++ src/gtfs_perstop_report/src/stop_schedule_pb2.pyi | 60 ++ src/gtfs_perstop_report/src/stop_times.py | 120 ++++ src/gtfs_perstop_report/src/stops.py | 100 ++++ src/gtfs_perstop_report/src/street_name.py | 49 ++ src/gtfs_perstop_report/src/trips.py | 134 +++++ src/gtfs_perstop_report/stop_report.py | 666 +++++++++++++++++++++ src/gtfs_perstop_report/uv.lock | 253 ++++++++ src/gtfs_vigo_stops/.gitignore | 13 - src/gtfs_vigo_stops/pyproject.toml | 27 - src/gtfs_vigo_stops/src/__init__.py | 0 src/gtfs_vigo_stops/src/common.py | 65 -- src/gtfs_vigo_stops/src/download.py | 130 ---- src/gtfs_vigo_stops/src/logger.py | 54 -- src/gtfs_vigo_stops/src/proto/__init__.py | 1 - src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py | 32 - .../src/proto/stop_schedule_pb2.pyi | 69 --- src/gtfs_vigo_stops/src/providers.py | 136 ----- src/gtfs_vigo_stops/src/report_writer.py | 145 ----- src/gtfs_vigo_stops/src/routes.py | 43 -- src/gtfs_vigo_stops/src/services.py | 113 ---- src/gtfs_vigo_stops/src/shapes.py | 88 --- src/gtfs_vigo_stops/src/stop_schedule_pb2.py | 41 -- src/gtfs_vigo_stops/src/stop_schedule_pb2.pyi | 60 -- src/gtfs_vigo_stops/src/stop_times.py | 120 ---- src/gtfs_vigo_stops/src/stops.py | 100 ---- src/gtfs_vigo_stops/src/street_name.py | 49 -- src/gtfs_vigo_stops/src/trips.py | 134 ----- src/gtfs_vigo_stops/stop_report.py | 666 --------------------- src/gtfs_vigo_stops/uv.lock | 253 -------- 53 files changed, 2474 insertions(+), 2379 deletions(-) create mode 100644 src/gtfs_perstop_report/.gitignore create mode 100644 src/gtfs_perstop_report/pyproject.toml create mode 100644 src/gtfs_perstop_report/src/__init__.py create mode 100644 src/gtfs_perstop_report/src/common.py create mode 100644 src/gtfs_perstop_report/src/download.py create mode 100644 src/gtfs_perstop_report/src/logger.py create mode 100644 src/gtfs_perstop_report/src/proto/__init__.py create mode 100644 src/gtfs_perstop_report/src/proto/stop_schedule_pb2.py create mode 100644 src/gtfs_perstop_report/src/proto/stop_schedule_pb2.pyi create mode 100644 src/gtfs_perstop_report/src/providers.py create mode 100644 src/gtfs_perstop_report/src/report_writer.py create mode 100644 src/gtfs_perstop_report/src/routes.py create mode 100644 src/gtfs_perstop_report/src/services.py create mode 100644 src/gtfs_perstop_report/src/shapes.py create mode 100644 src/gtfs_perstop_report/src/stop_schedule_pb2.py create mode 100644 src/gtfs_perstop_report/src/stop_schedule_pb2.pyi create mode 100644 src/gtfs_perstop_report/src/stop_times.py create mode 100644 src/gtfs_perstop_report/src/stops.py create mode 100644 src/gtfs_perstop_report/src/street_name.py create mode 100644 src/gtfs_perstop_report/src/trips.py create mode 100644 src/gtfs_perstop_report/stop_report.py create mode 100644 src/gtfs_perstop_report/uv.lock delete mode 100644 src/gtfs_vigo_stops/.gitignore delete mode 100644 src/gtfs_vigo_stops/pyproject.toml delete mode 100644 src/gtfs_vigo_stops/src/__init__.py delete mode 100644 src/gtfs_vigo_stops/src/common.py delete mode 100644 src/gtfs_vigo_stops/src/download.py delete mode 100644 src/gtfs_vigo_stops/src/logger.py delete mode 100644 src/gtfs_vigo_stops/src/proto/__init__.py delete mode 100644 src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py delete mode 100644 src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi delete mode 100644 src/gtfs_vigo_stops/src/providers.py delete mode 100644 src/gtfs_vigo_stops/src/report_writer.py delete mode 100644 src/gtfs_vigo_stops/src/routes.py delete mode 100644 src/gtfs_vigo_stops/src/services.py delete mode 100644 src/gtfs_vigo_stops/src/shapes.py delete mode 100644 src/gtfs_vigo_stops/src/stop_schedule_pb2.py delete mode 100644 src/gtfs_vigo_stops/src/stop_schedule_pb2.pyi delete mode 100644 src/gtfs_vigo_stops/src/stop_times.py delete mode 100644 src/gtfs_vigo_stops/src/stops.py delete mode 100644 src/gtfs_vigo_stops/src/street_name.py delete mode 100644 src/gtfs_vigo_stops/src/trips.py delete mode 100644 src/gtfs_vigo_stops/stop_report.py delete mode 100644 src/gtfs_vigo_stops/uv.lock (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs index 55e880f..f114ec3 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs @@ -28,7 +28,7 @@ public class RenfeTransitProvider : ITransitProvider } var now = nowLocal.AddSeconds(60 - nowLocal.Second); - var scopeEnd = now.AddMinutes(300); + var scopeEnd = now.AddMinutes(8 * 60); var scheduledWindow = stopArrivals.Arrivals .Where(c => c.CallingDateTime(nowLocal.Date) != null) @@ -49,11 +49,12 @@ public class RenfeTransitProvider : ITransitProvider { Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now, Minutes = minutes, - TripId = sched.TripId, - ServiceId = sched.ServiceId, + TripId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)], + ServiceId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)], ShapeId = sched.ShapeId, }, - RealTime = null + RealTime = null, + NextStreets = [.. sched.NextStreets] }); } diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 59f2724..12a54da 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -3,11 +3,11 @@ import { type ReactNode } from "react"; import { type RegionId } from "./config/RegionConfig"; import { MapProvider, useMap } from "./contexts/MapContext"; import { - SettingsProvider, - useSettings, - type MapPositionMode, - type TableStyle, - type Theme, + SettingsProvider, + useSettings, + type MapPositionMode, + type TableStyle, + type Theme, } from "./contexts/SettingsContext"; // Re-export types for compatibility @@ -21,6 +21,9 @@ export const useApp = () => { return { ...settings, ...map, + // Mock region support for now since we only have one region + region: "vigo" as RegionId, + setRegion: (region: RegionId) => { console.log("Set region", region); }, }; }; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 6363c85..6492d39 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -87,6 +87,16 @@ --line-gol: hsl(208, 68%, 66%); --line-gol-text: hsl(0, 0%, 100%); + --line-md: hsl(316, 99%, 27%); + --line-md-text: hsl(0, 0%, 100%); + --line-ave: hsl(316, 99%, 27%); + --line-ave-text: hsl(0, 0%, 100%); + --line-alvia: hsl(316, 99%, 27%); + --line-alvia-text: hsl(0, 0%, 100%); + --line-trencelta: hsl(135, 58%, 25%); + --line-trencelta-text: hsl(0, 0%, 100%); + --line-regional: hsl(316, 99%, 27%); + --line-regional-text: hsl(0, 0%, 100%); } .line-icon-default { diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css index 57d30c8..9922b03 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css @@ -61,7 +61,6 @@ } .consolidated-circulation-card .route-info strong { - font-size: 1rem; color: var(--text-color); overflow: hidden; text-overflow: ellipsis; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index 635c0ce..70a9355 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Marquee from 'react-fast-marquee'; import { useTranslation } from "react-i18next"; import LineIcon from "~components/LineIcon"; @@ -12,6 +12,7 @@ interface ConsolidatedCirculationCardProps { onMapClick?: () => void; readonly?: boolean; reduced?: boolean; + driver?: string; } // Utility function to parse service ID and get the turn number @@ -71,9 +72,52 @@ const parseServiceId = (serviceId: string): string => { return `${displayLine}-${turnNumber}`; }; +const AutoMarquee = ({ text }: { text: string }) => { + const containerRef = useRef(null); + const [shouldScroll, setShouldScroll] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const checkScroll = () => { + // 9px per char for text-sm font-mono is a safe upper bound estimate + // (14px * 0.6 = 8.4px) + const charWidth = 9; + const availableWidth = el.offsetWidth; + const textWidth = text.length * charWidth; + + setShouldScroll(textWidth > availableWidth); + }; + + checkScroll(); + + const observer = new ResizeObserver(checkScroll); + observer.observe(el); + + return () => observer.disconnect(); + }, [text]); + + if (shouldScroll) { + return ( +
+ +
{text}
+
+
+ ); + } + + return ( +
+ {text} +
+ ); +}; + export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, onMapClick, readonly, reduced }) => { +> = ({ estimate, onMapClick, readonly, reduced, driver }) => { const { t } = useTranslation(); const formatDistance = (meters: number) => { @@ -157,7 +201,7 @@ export const ConsolidatedCirculationCard: React.FC< chips.push(delayChip); } - if (estimate.schedule) { + if (estimate.schedule && driver !== 'renfe') { chips.push({ label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay( estimate.schedule.tripId @@ -199,14 +243,17 @@ export const ConsolidatedCirculationCard: React.FC< // Check if bus has GPS position (live tracking) const hasGpsPosition = !!estimate.currentPosition; + const isRenfe = driver === 'renfe'; + const isClickable = hasGpsPosition; + const looksDisabled = !isClickable && !isRenfe; const Tag = readonly ? "div" : "button"; const interactiveProps = readonly ? {} : { - onClick: onMapClick, + onClick: isClickable ? onMapClick : undefined, type: "button" as const, - disabled: !hasGpsPosition, + disabled: !isClickable, }; if (reduced) { @@ -217,12 +264,14 @@ export const ConsolidatedCirculationCard: React.FC< bg-(--message-background-color) border border-(--border-color) rounded-xl px-3 py-2.5 transition-all ${readonly - ? !hasGpsPosition + ? looksDisabled ? "opacity-70 cursor-not-allowed" : "" - : hasGpsPosition + : isClickable ? "cursor-pointer hover:shadow-[0_4px_14px_rgba(0,0,0,0.08)] hover:border-(--button-background-color) hover:bg-[color-mix(in_oklab,var(--button-background-color)_5%,var(--message-background-color))] active:scale-[0.98]" - : "opacity-70 cursor-not-allowed" + : looksDisabled + ? "opacity-70 cursor-not-allowed" + : "" } `.trim()} {...interactiveProps} @@ -232,6 +281,9 @@ export const ConsolidatedCirculationCard: React.FC<
+ {driver === 'renfe' && estimate.schedule?.tripId && ( + {estimate.schedule.tripId} + )} {estimate.route} {metaChips.length > 0 && ( @@ -295,12 +347,14 @@ export const ConsolidatedCirculationCard: React.FC< return ( @@ -310,17 +364,15 @@ export const ConsolidatedCirculationCard: React.FC<
- {estimate.route} - {estimate.nextStreets && estimate.nextStreets.length > 0 && (() => { - const text = estimate.nextStreets.join(" — "); - return ( - 30}> -
- {text} -
-
- ); - })()} + + {driver === 'renfe' && estimate.schedule?.tripId && ( + {estimate.schedule.tripId} + )} + {estimate.route} + + {estimate.nextStreets && estimate.nextStreets.length > 0 && ( + + )}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 088f978..ec79f1c 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -9,12 +9,14 @@ interface ConsolidatedCirculationListProps { data: ConsolidatedCirculation[]; onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; reduced?: boolean; + driver?: string; } export const ConsolidatedCirculationList: React.FC = ({ data, onCirculationClick, reduced, + driver, }) => { const { t } = useTranslation(); @@ -43,6 +45,7 @@ export const ConsolidatedCirculationList: React.FC ( onCirculationClick?.(estimate, idx)} diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index 4677509..43fe70a 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -1,6 +1,26 @@ import type { LngLatLike } from "maplibre-gl"; -export const REGION_DATA = { +export type RegionId = "vigo"; + +export interface RegionData { + id: RegionId; + name: string; + stopsEndpoint: string; + estimatesEndpoint: string; + consolidatedCirculationsEndpoint: string; + timetableEndpoint: string; + shapeEndpoint: string; + defaultCenter: LngLatLike; + bounds: { + sw: LngLatLike; + ne: LngLatLike; + }; + textColour: string; + defaultZoom: number; + showMeters: boolean; +} + +export const REGION_DATA: RegionData = { id: "vigo", name: "Vigo", stopsEndpoint: "/stops/vigo.json", @@ -20,3 +40,6 @@ export const REGION_DATA = { defaultZoom: 14, showMeters: true, }; + +export const getAvailableRegions = (): RegionData[] => [REGION_DATA]; + diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 5d56b48..f97fdf7 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -104,7 +104,7 @@ export default function StopList() { } if (!userLocation) { - return [...data].sort((a, b) => a.stopId - b.stopId); + return [...data].sort((a, b) => a.stopId.localeCompare(b.stopId)); } const toRadians = (value: number) => (value * Math.PI) / 180; @@ -147,7 +147,7 @@ export default function StopList() { }) .sort((a, b) => { if (a.distance === b.distance) { - return a.stop.stopId - b.stop.stopId; + return a.stop.stopId.localeCompare(b.stop.stopId); } return a.distance - b.distance; }) @@ -216,8 +216,8 @@ export default function StopList() { let items: Stop[]; if (isNumericSearch) { // Direct match for stop codes - const stopId = parseInt(searchQuery.trim(), 10); - const exactMatch = data.filter((stop) => stop.stopId === stopId); + const stopId = searchQuery.trim(); + const exactMatch = data.filter((stop) => stop.stopId === stopId || stop.stopId.endsWith(`:${stopId}`)); if (exactMatch.length > 0) { items = exactMatch; } else { @@ -281,7 +281,7 @@ export default function StopList() { {/* Favourites Gallery */} {!loading && ( a.stopId - b.stopId)} + stops={favouriteStops.sort((a, b) => a.stopId.localeCompare(b.stopId))} title={t("stoplist.favourites")} emptyMessage={t("stoplist.no_favourites")} /> diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 5260c32..553b8e7 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -247,8 +247,8 @@ export default function Estimates() {
@@ -273,6 +273,7 @@ export default function Estimates() { { setSelectedCirculationId(getCirculationId(estimate)); setIsMapModalOpen(true); diff --git a/src/gtfs_perstop_report/.gitignore b/src/gtfs_perstop_report/.gitignore new file mode 100644 index 0000000..2be2c5f --- /dev/null +++ b/src/gtfs_perstop_report/.gitignore @@ -0,0 +1,13 @@ +feed/ +output/ + +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/src/gtfs_perstop_report/pyproject.toml b/src/gtfs_perstop_report/pyproject.toml new file mode 100644 index 0000000..97d24a3 --- /dev/null +++ b/src/gtfs_perstop_report/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "gtfs-vigo" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "colorama>=0.4.6", + "jinja2>=3.1.6", + "protobuf>=5.29.1", + "pyproj>=3.7.2", + "pytest>=8.4.1", + "requests>=2.32.3", +] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + diff --git a/src/gtfs_perstop_report/src/__init__.py b/src/gtfs_perstop_report/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gtfs_perstop_report/src/common.py b/src/gtfs_perstop_report/src/common.py new file mode 100644 index 0000000..22769e4 --- /dev/null +++ b/src/gtfs_perstop_report/src/common.py @@ -0,0 +1,67 @@ +""" +Common utilities for GTFS report generation. +""" + +import csv +import os +from datetime import datetime, timedelta +from typing import List + + +def get_all_feed_dates(feed_dir: str) -> List[str]: + """ + Returns all dates the feed is valid for, using calendar.txt if present, else calendar_dates.txt. + """ + calendar_path = os.path.join(feed_dir, "calendar.txt") + calendar_dates_path = os.path.join(feed_dir, "calendar_dates.txt") + + # Try calendar.txt first + if os.path.exists(calendar_path): + with open(calendar_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + if rows: # Check if there's actual data + start_dates: List[str] = [] + end_dates: List[str] = [] + for row in rows: + if row.get("start_date") and row.get("end_date"): + start_dates.append(row["start_date"]) + end_dates.append(row["end_date"]) + if start_dates and end_dates: + min_date = min(start_dates) + max_date = max(end_dates) + # Convert YYYYMMDD to YYYY-MM-DD + start = datetime.strptime(min_date, "%Y%m%d") + end = datetime.strptime(max_date, "%Y%m%d") + result: List[str] = [] + while start <= end: + result.append(start.strftime("%Y-%m-%d")) + start += timedelta(days=1) + if len(result) > 0: + return result + + + # Fallback: use calendar_dates.txt + if os.path.exists(calendar_dates_path): + with open(calendar_dates_path, encoding="utf-8") as f: + reader = csv.DictReader(f) + dates: set[str] = set() + for row in reader: + if row.get("exception_type") == "1" and row.get("date"): + # Convert YYYYMMDD to YYYY-MM-DD + d = row["date"] + dates.add(f"{d[:4]}-{d[4:6]}-{d[6:]}") + if len(dates) > 0: + return sorted(dates) + + today = datetime.today() + return [(today + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(8)] + + +def time_to_seconds(time_str: str) -> int: + """Convert HH:MM:SS to seconds since midnight.""" + parts = time_str.split(":") + if len(parts) != 3: + return 0 + hours, minutes, seconds = map(int, parts) + return hours * 3600 + minutes * 60 + seconds diff --git a/src/gtfs_perstop_report/src/download.py b/src/gtfs_perstop_report/src/download.py new file mode 100644 index 0000000..19125bc --- /dev/null +++ b/src/gtfs_perstop_report/src/download.py @@ -0,0 +1,130 @@ +import os +import tempfile +import zipfile +import requests +import json +from typing import Optional, Tuple + +from src.logger import get_logger + +logger = get_logger("download") + +def _get_metadata_path(output_dir: str) -> str: + """Get the path to the metadata file for storing ETag and Last-Modified info.""" + return os.path.join(output_dir, '.gtfsmetadata') + +def _load_metadata(output_dir: str) -> Optional[dict]: + """Load existing metadata from the output directory.""" + metadata_path = _get_metadata_path(output_dir) + if os.path.exists(metadata_path): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.warning(f"Failed to load metadata from {metadata_path}: {e}") + return None + +def _save_metadata(output_dir: str, etag: Optional[str], last_modified: Optional[str]) -> None: + """Save ETag and Last-Modified metadata to the output directory.""" + metadata_path = _get_metadata_path(output_dir) + metadata = { + 'etag': etag, + 'last_modified': last_modified + } + + # Ensure output directory exists + os.makedirs(output_dir, exist_ok=True) + + try: + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2) + except IOError as e: + logger.warning(f"Failed to save metadata to {metadata_path}: {e}") + +def _check_if_modified(feed_url: str, output_dir: str) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Check if the feed has been modified using conditional headers. + Returns (is_modified, etag, last_modified) + """ + metadata = _load_metadata(output_dir) + if not metadata: + return True, None, None + + headers = {} + if metadata.get('etag'): + headers['If-None-Match'] = metadata['etag'] + if metadata.get('last_modified'): + headers['If-Modified-Since'] = metadata['last_modified'] + + if not headers: + return True, None, None + + try: + response = requests.head(feed_url, headers=headers) + + if response.status_code == 304: + logger.info("Feed has not been modified (304 Not Modified), skipping download") + return False, metadata.get('etag'), metadata.get('last_modified') + elif response.status_code == 200: + etag = response.headers.get('ETag') + last_modified = response.headers.get('Last-Modified') + return True, etag, last_modified + else: + logger.warning(f"Unexpected response status {response.status_code} when checking for modifications, proceeding with download") + return True, None, None + except requests.RequestException as e: + logger.warning(f"Failed to check if feed has been modified: {e}, proceeding with download") + return True, None, None + +def download_feed_from_url(feed_url: str, output_dir: str = None, force_download: bool = False) -> Optional[str]: + """ + Download GTFS feed from URL. + + Args: + feed_url: URL to download the GTFS feed from + output_dir: Directory where reports will be written (used for metadata storage) + force_download: If True, skip conditional download checks + + Returns: + Path to the directory containing the extracted GTFS files, or None if download was skipped + """ + + # Check if we need to download the feed + if not force_download and output_dir: + is_modified, cached_etag, cached_last_modified = _check_if_modified(feed_url, output_dir) + if not is_modified: + logger.info("Feed has not been modified, skipping download") + return None + + # Create a directory in the system temporary directory + temp_dir = tempfile.mkdtemp(prefix='gtfs_vigo_') + + # Create a temporary zip file in the temporary directory + zip_filename = os.path.join(temp_dir, 'gtfs_vigo.zip') + + headers = {} + response = requests.get(feed_url, headers=headers) + + if response.status_code != 200: + raise Exception(f"Failed to download GTFS data: {response.status_code}") + + with open(zip_filename, 'wb') as file: + file.write(response.content) + + # Extract and save metadata if output_dir is provided + if output_dir: + etag = response.headers.get('ETag') + last_modified = response.headers.get('Last-Modified') + if etag or last_modified: + _save_metadata(output_dir, etag, last_modified) + + # Extract the zip file + with zipfile.ZipFile(zip_filename, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + # Clean up the downloaded zip file + os.remove(zip_filename) + + logger.info(f"GTFS feed downloaded from {feed_url} and extracted to {temp_dir}") + + return temp_dir \ No newline at end of file diff --git a/src/gtfs_perstop_report/src/logger.py b/src/gtfs_perstop_report/src/logger.py new file mode 100644 index 0000000..9488076 --- /dev/null +++ b/src/gtfs_perstop_report/src/logger.py @@ -0,0 +1,54 @@ +""" +Logging configuration for the GTFS application. +""" +import logging +from colorama import init, Fore, Style + +# Initialize Colorama (required on Windows) +init(autoreset=True) + +class ColorFormatter(logging.Formatter): + def format(self, record: logging.LogRecord): + # Base format + log_format = "%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s" + + # Apply colors based on log level + if record.levelno == logging.DEBUG: + prefix = Style.DIM + Fore.WHITE # "Dark grey" + elif record.levelno == logging.INFO: + prefix = Fore.CYAN + elif record.levelno == logging.WARNING: + prefix = Fore.YELLOW + elif record.levelno == logging.ERROR: + prefix = Fore.RED + elif record.levelno == logging.CRITICAL: + prefix = Style.BRIGHT + Fore.RED + else: + prefix = "" + + # Add color to the entire line + formatter = logging.Formatter( + prefix + log_format + Style.RESET_ALL, "%Y-%m-%d %H:%M:%S") + return formatter.format(record) + +def get_logger(name: str) -> logging.Logger: + """ + Create and return a logger with the given name. + + Args: + name (str): The name of the logger. + + Returns: + logging.Logger: Configured logger instance. + """ + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + + # Only add handler if it doesn't already have one + if not logger.handlers: + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + console_handler.setFormatter(ColorFormatter()) + logger.addHandler(console_handler) + + return logger diff --git a/src/gtfs_perstop_report/src/proto/__init__.py b/src/gtfs_perstop_report/src/proto/__init__.py new file mode 100644 index 0000000..b775c17 --- /dev/null +++ b/src/gtfs_perstop_report/src/proto/__init__.py @@ -0,0 +1 @@ +# Protobuf generated files diff --git a/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.py b/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.py new file mode 100644 index 0000000..cb4f336 --- /dev/null +++ b/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: stop_schedule.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\x83\x04\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\x85\x03\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\t\x12\x1e\n\x16previous_trip_shape_id\x18\x33 \x01(\t\";\n\x05Shape\x12\x10\n\x08shape_id\x18\x01 \x01(\t\x12 \n\x06points\x18\x03 \x03(\x0b\x32\x10.proto.Epsg25829B$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' + _EPSG25829._serialized_start=30 + _EPSG25829._serialized_end=63 + _STOPARRIVALS._serialized_start=66 + _STOPARRIVALS._serialized_end=581 + _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_start=192 + _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_end=581 + _SHAPE._serialized_start=583 + _SHAPE._serialized_end=642 +# @@protoc_insertion_point(module_scope) diff --git a/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.pyi b/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.pyi new file mode 100644 index 0000000..355798f --- /dev/null +++ b/src/gtfs_perstop_report/src/proto/stop_schedule_pb2.pyi @@ -0,0 +1,69 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Epsg25829(_message.Message): + __slots__ = ["x", "y"] + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> None: ... + +class Shape(_message.Message): + __slots__ = ["points", "shape_id"] + POINTS_FIELD_NUMBER: _ClassVar[int] + SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + points: _containers.RepeatedCompositeFieldContainer[Epsg25829] + shape_id: str + def __init__(self, shape_id: _Optional[str] = ..., points: _Optional[_Iterable[_Union[Epsg25829, _Mapping]]] = ...) -> None: ... + +class StopArrivals(_message.Message): + __slots__ = ["arrivals", "location", "stop_id"] + class ScheduledArrival(_message.Message): + __slots__ = ["calling_ssm", "calling_time", "line", "next_streets", "previous_trip_shape_id", "route", "service_id", "shape_dist_traveled", "shape_id", "starting_code", "starting_name", "starting_time", "stop_sequence", "terminus_code", "terminus_name", "terminus_time", "trip_id"] + CALLING_SSM_FIELD_NUMBER: _ClassVar[int] + CALLING_TIME_FIELD_NUMBER: _ClassVar[int] + LINE_FIELD_NUMBER: _ClassVar[int] + NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] + PREVIOUS_TRIP_SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + ROUTE_FIELD_NUMBER: _ClassVar[int] + SERVICE_ID_FIELD_NUMBER: _ClassVar[int] + SHAPE_DIST_TRAVELED_FIELD_NUMBER: _ClassVar[int] + SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + STARTING_CODE_FIELD_NUMBER: _ClassVar[int] + STARTING_NAME_FIELD_NUMBER: _ClassVar[int] + STARTING_TIME_FIELD_NUMBER: _ClassVar[int] + STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] + TERMINUS_CODE_FIELD_NUMBER: _ClassVar[int] + TERMINUS_NAME_FIELD_NUMBER: _ClassVar[int] + TERMINUS_TIME_FIELD_NUMBER: _ClassVar[int] + TRIP_ID_FIELD_NUMBER: _ClassVar[int] + calling_ssm: int + calling_time: str + line: str + next_streets: _containers.RepeatedScalarFieldContainer[str] + previous_trip_shape_id: str + route: str + service_id: str + shape_dist_traveled: float + shape_id: str + starting_code: str + starting_name: str + starting_time: str + stop_sequence: int + terminus_code: str + terminus_name: str + terminus_time: str + trip_id: str + def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ..., previous_trip_shape_id: _Optional[str] = ...) -> None: ... + ARRIVALS_FIELD_NUMBER: _ClassVar[int] + LOCATION_FIELD_NUMBER: _ClassVar[int] + STOP_ID_FIELD_NUMBER: _ClassVar[int] + arrivals: _containers.RepeatedCompositeFieldContainer[StopArrivals.ScheduledArrival] + location: Epsg25829 + stop_id: str + def __init__(self, stop_id: _Optional[str] = ..., location: _Optional[_Union[Epsg25829, _Mapping]] = ..., arrivals: _Optional[_Iterable[_Union[StopArrivals.ScheduledArrival, _Mapping]]] = ...) -> None: ... diff --git a/src/gtfs_perstop_report/src/providers.py b/src/gtfs_perstop_report/src/providers.py new file mode 100644 index 0000000..fa04261 --- /dev/null +++ b/src/gtfs_perstop_report/src/providers.py @@ -0,0 +1,137 @@ +""" +Provider-specific configuration for different GTFS feed formats. +""" + +from typing import Protocol, Optional +from src.street_name import get_street_name + + +class FeedProvider(Protocol): + """Protocol defining provider-specific behavior for GTFS feeds.""" + + @staticmethod + def format_service_id(service_id: str) -> str: + """Format service_id for output.""" + ... + + @staticmethod + def format_trip_id(trip_id: str) -> str: + """Format trip_id for output.""" + ... + + @staticmethod + def format_route(route: str, terminus_name: str) -> str: + """Format route/headsign, potentially using terminus name as fallback.""" + ... + + @staticmethod + def extract_street_name(stop_name: str) -> str: + """Extract street name from stop name, or return full name.""" + ... + + +class VitrasaProvider: + """Provider configuration for Vitrasa (Vigo bus system).""" + + @staticmethod + def format_service_id(service_id: str) -> str: + """Extract middle part from underscore-separated service_id.""" + parts = service_id.split("_") + return parts[1] if len(parts) >= 2 else service_id + + @staticmethod + def format_trip_id(trip_id: str) -> str: + """Extract middle parts from underscore-separated trip_id.""" + parts = trip_id.split("_") + return "_".join(parts[1:3]) if len(parts) >= 3 else trip_id + + @staticmethod + def format_route(route: str, terminus_name: str) -> str: + """Return route as-is for Vitrasa.""" + return route + + @staticmethod + def extract_street_name(stop_name: str) -> str: + """Extract street name from stop name using standard logic.""" + return get_street_name(stop_name) or "" + + +class RenfeProvider: + """Provider configuration for Renfe (Spanish rail system).""" + + @staticmethod + def format_service_id(service_id: str) -> str: + """Use full service_id for Renfe (no underscores).""" + return service_id + + @staticmethod + def format_trip_id(trip_id: str) -> str: + """Use full trip_id for Renfe (no underscores).""" + return trip_id + + @staticmethod + def format_route(route: str, terminus_name: str) -> str: + """Use terminus name as route if route is empty.""" + val = route if route else terminus_name + return val.replace("NY", "Ñ").replace("ny", "ñ") + + @staticmethod + def extract_street_name(stop_name: str) -> str: + """Preserve full stop name for train stations.""" + return stop_name.replace("NY", "Ñ").replace("ny", "ñ") + + +class DefaultProvider: + """Default provider configuration for generic GTFS feeds.""" + + @staticmethod + def format_service_id(service_id: str) -> str: + """Try to extract from underscores, fallback to full ID.""" + parts = service_id.split("_") + return parts[1] if len(parts) >= 2 else service_id + + @staticmethod + def format_trip_id(trip_id: str) -> str: + """Try to extract from underscores, fallback to full ID.""" + parts = trip_id.split("_") + return "_".join(parts[1:3]) if len(parts) >= 3 else trip_id + + @staticmethod + def format_route(route: str, terminus_name: str) -> str: + """Use terminus name as route if route is empty.""" + return route if route else terminus_name + + @staticmethod + def extract_street_name(stop_name: str) -> str: + """Extract street name from stop name using standard logic.""" + return get_street_name(stop_name) or "" + + +# Provider registry +PROVIDERS = { + "vitrasa": VitrasaProvider, + "renfe": RenfeProvider, + "default": DefaultProvider, +} + + +def get_provider(provider_name: str) -> type[FeedProvider]: + """ + Get provider configuration by name. + + Args: + provider_name: Name of the provider (case-insensitive) + + Returns: + Provider class with configuration methods + + Raises: + ValueError: If provider name is not recognized + """ + provider_name_lower = provider_name.lower() + if provider_name_lower not in PROVIDERS: + raise ValueError( + f"Unknown provider: {provider_name}. " + f"Available providers: {', '.join(PROVIDERS.keys())}" + ) + return PROVIDERS[provider_name_lower] diff --git a/src/gtfs_perstop_report/src/report_writer.py b/src/gtfs_perstop_report/src/report_writer.py new file mode 100644 index 0000000..f6d8763 --- /dev/null +++ b/src/gtfs_perstop_report/src/report_writer.py @@ -0,0 +1,145 @@ +""" +Report writers for various output formats (HTML, JSON). +Centralizes all write operations for different report types. +""" + +import json +import os +from typing import Any, Dict, List + +from src.logger import get_logger +from src.proto.stop_schedule_pb2 import Epsg25829, StopArrivals + + +def write_stop_protobuf( + output_dir: str, + date: str, + stop_code: str, + arrivals: List[Dict[str, Any]], + stop_x: float, + stop_y: float, +) -> None: + """ + Write stop arrivals data to a Protobuf file. + + Args: + output_dir: Base output directory + date: Date string for the data + stop_code: Stop code identifier + stop_arrivals_proto: Serialized Protobuf data + """ + logger = get_logger("report_writer") + + item = StopArrivals( + stop_id=stop_code, + location=Epsg25829(x=stop_x, y=stop_y), + arrivals=[ + StopArrivals.ScheduledArrival( + service_id=arrival["service_id"], + trip_id=arrival["trip_id"], + line=arrival["line"], + route=arrival["route"], + shape_id=arrival["shape_id"], + shape_dist_traveled=arrival["shape_dist_traveled"], + stop_sequence=arrival["stop_sequence"], + next_streets=arrival["next_streets"], + starting_code=arrival["starting_code"], + starting_name=arrival["starting_name"], + starting_time=arrival["starting_time"], + calling_time=arrival["calling_time"], + calling_ssm=arrival["calling_ssm"], + terminus_code=arrival["terminus_code"], + terminus_name=arrival["terminus_name"], + terminus_time=arrival["terminus_time"], + previous_trip_shape_id=arrival.get("previous_trip_shape_id", ""), + ) + for arrival in arrivals + ], + ) + + try: + # Create the stops directory for this date + date_dir = os.path.join(output_dir, date) + os.makedirs(date_dir, exist_ok=True) + + # Create the Protobuf file + file_path = os.path.join(date_dir, f"{stop_code}.pb") + + with open(file_path, "wb") as f: + f.write(item.SerializeToString()) + + logger.debug(f"Stop Protobuf written to: {file_path}") + except Exception as e: + logger.error( + f"Error writing stop Protobuf to {output_dir}/stops/{date}/{stop_code}.pb: {e}" + ) + raise + + +def write_stop_json( + output_dir: str, date: str, stop_code: str, arrivals: List[Dict[str, Any]] +) -> None: + """ + Write stop arrivals data to a JSON file. + + Args: + output_dir: Base output directory + date: Date string for the data + stop_code: Stop code identifier + arrivals: List of arrival dictionaries + pretty: Whether to format JSON with indentation + """ + logger = get_logger("report_writer") + + try: + # Create the stops directory for this date + date_dir = os.path.join(output_dir, date) + os.makedirs(date_dir, exist_ok=True) + + # Create the JSON file + file_path = os.path.join(date_dir, f"{stop_code}.json") + + with open(file_path, "w", encoding="utf-8") as f: + json.dump(arrivals, f, ensure_ascii=False) + + logger.debug(f"Stop JSON written to: {file_path}") + except Exception as e: + logger.error( + f"Error writing stop JSON to {output_dir}/stops/{date}/{stop_code}.json: {e}" + ) + raise + + +def write_index_json( + output_dir: str, + data: Dict[str, Any], + filename: str = "index.json", + pretty: bool = False, +) -> None: + """ + Write index data to a JSON file. + + Args: + output_dir: Directory where the JSON file should be written + data: Dictionary containing the index data + filename: Name of the JSON file (default: "index.json") + pretty: Whether to format JSON with indentation + """ + logger = get_logger("report_writer") + + try: + # Create the output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Write the index.json file + index_filepath = os.path.join(output_dir, filename) + with open(index_filepath, "w", encoding="utf-8") as f: + if pretty: + json.dump(data, f, ensure_ascii=False, indent=2) + else: + json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + + logger.info(f"Index JSON written to: {index_filepath}") + except Exception as e: + logger.error(f"Error writing index JSON to {output_dir}/{filename}: {e}") + raise diff --git a/src/gtfs_perstop_report/src/routes.py b/src/gtfs_perstop_report/src/routes.py new file mode 100644 index 0000000..e67a1a4 --- /dev/null +++ b/src/gtfs_perstop_report/src/routes.py @@ -0,0 +1,43 @@ +""" +Module for loading and querying GTFS routes data. +""" +import os +import csv +from src.logger import get_logger + +logger = get_logger("routes") + +def load_routes(feed_dir: str) -> dict[str, dict[str, str]]: + """ + Load routes data from the GTFS feed. + + Returns: + dict[str, dict[str, str]]: A dictionary where keys are route IDs and values are dictionaries + containing route_short_name and route_color. + """ + routes: dict[str, dict[str, str]] = {} + routes_file_path = os.path.join(feed_dir, 'routes.txt') + + try: + with open(routes_file_path, 'r', encoding='utf-8') as routes_file: + reader = csv.DictReader(routes_file) + header = reader.fieldnames or [] + if 'route_color' not in header: + logger.warning("Column 'route_color' not found in routes.txt. Defaulting to black (#000000).") + + for row in reader: + route_id = row['route_id'] + if 'route_color' in row and row['route_color']: + route_color = row['route_color'] + else: + route_color = '000000' + routes[route_id] = { + 'route_short_name': row['route_short_name'], + 'route_color': route_color + } + except FileNotFoundError: + raise FileNotFoundError(f"Routes file not found at {routes_file_path}") + except KeyError as e: + raise KeyError(f"Missing required column in routes file: {e}") + + return routes diff --git a/src/gtfs_perstop_report/src/services.py b/src/gtfs_perstop_report/src/services.py new file mode 100644 index 0000000..fb1110d --- /dev/null +++ b/src/gtfs_perstop_report/src/services.py @@ -0,0 +1,113 @@ +import os +import datetime +from src.logger import get_logger + +logger = get_logger("services") + + +def get_active_services(feed_dir: str, date: str) -> list[str]: + """ + Get active services for a given date based on the 'calendar.txt' and 'calendar_dates.txt' files. + + Args: + date (str): Date in 'YYYY-MM-DD' format. + + Returns: + list[str]: List of active service IDs for the given date. + + Raises: + ValueError: If the date format is incorrect. + """ + search_date = date.replace("-", "").replace(":", "").replace("/", "") + weekday = datetime.datetime.strptime(date, '%Y-%m-%d').weekday() + active_services: list[str] = [] + + try: + with open(os.path.join(feed_dir, 'calendar.txt'), 'r', encoding="utf-8") as calendar_file: + lines = calendar_file.readlines() + if len(lines) > 1: + # First parse the header, get each column's index + header = lines[0].strip().split(',') + try: + service_id_index = header.index('service_id') + monday_index = header.index('monday') + tuesday_index = header.index('tuesday') + wednesday_index = header.index('wednesday') + thursday_index = header.index('thursday') + friday_index = header.index('friday') + saturday_index = header.index('saturday') + sunday_index = header.index('sunday') + start_date_index = header.index('start_date') + end_date_index = header.index('end_date') + except ValueError as e: + logger.error(f"Required column not found in header: {e}") + return active_services + # Now read the rest of the file, find all services where the day of the week matches + weekday_columns = { + 0: monday_index, + 1: tuesday_index, + 2: wednesday_index, + 3: thursday_index, + 4: friday_index, + 5: saturday_index, + 6: sunday_index + } + + for idx, line in enumerate(lines[1:], 1): + parts = line.strip().split(',') + if len(parts) < len(header): + logger.warning( + f"Skipping malformed line in calendar.txt line {idx+1}: {line.strip()}") + continue + + service_id = parts[service_id_index] + day_value = parts[weekday_columns[weekday]] + start_date = parts[start_date_index] + end_date = parts[end_date_index] + + # Check if day of week is active AND date is within the service range + if day_value == '1' and start_date <= search_date <= end_date: + active_services.append(service_id) + except FileNotFoundError: + logger.warning("calendar.txt file not found.") + + try: + with open(os.path.join(feed_dir, 'calendar_dates.txt'), 'r', encoding="utf-8") as calendar_dates_file: + lines = calendar_dates_file.readlines() + if len(lines) <= 1: + logger.warning( + "calendar_dates.txt file is empty or has only header line, not processing.") + return active_services + + header = lines[0].strip().split(',') + try: + service_id_index = header.index('service_id') + date_index = header.index('date') + exception_type_index = header.index('exception_type') + except ValueError as e: + logger.error(f"Required column not found in header: {e}") + return active_services + + # Now read the rest of the file, find all services where 'date' matches the search_date + # Start from 1 to skip header + for idx, line in enumerate(lines[1:], 1): + parts = line.strip().split(',') + if len(parts) < len(header): + logger.warning( + f"Skipping malformed line in calendar_dates.txt line {idx+1}: {line.strip()}") + continue + + service_id = parts[service_id_index] + date_value = parts[date_index] + exception_type = parts[exception_type_index] + + if date_value == search_date and exception_type == '1': + active_services.append(service_id) + + if date_value == search_date and exception_type == '2': + if service_id in active_services: + active_services.remove(service_id) + except FileNotFoundError: + logger.warning("calendar_dates.txt file not found.") + + return active_services diff --git a/src/gtfs_perstop_report/src/shapes.py b/src/gtfs_perstop_report/src/shapes.py new file mode 100644 index 0000000..f49832a --- /dev/null +++ b/src/gtfs_perstop_report/src/shapes.py @@ -0,0 +1,88 @@ +import csv +from dataclasses import dataclass +import os +from typing import Dict, Optional + +from pyproj import Transformer + +from src.logger import get_logger + + +logger = get_logger("shapes") + + +@dataclass +class Shape: + shape_id: str + shape_pt_lat: Optional[float] + shape_pt_lon: Optional[float] + shape_pt_position: Optional[int] + shape_dist_traveled: Optional[float] + + shape_pt_25829_x: Optional[float] = None + shape_pt_25829_y: Optional[float] = None + + +def process_shapes(feed_dir: str, out_dir: str) -> None: + file_path = os.path.join(feed_dir, "shapes.txt") + shapes: Dict[str, list[Shape]] = {} + + transformer = Transformer.from_crs(4326, 25829, always_xy=True) + + try: + with open(file_path, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f, quotechar='"', delimiter=",") + for row_num, row in enumerate(reader, start=2): + try: + shape = Shape( + shape_id=row["shape_id"], + shape_pt_lat=float(row["shape_pt_lat"]) if row.get("shape_pt_lat") else None, + shape_pt_lon=float(row["shape_pt_lon"]) if row.get("shape_pt_lon") else None, + shape_pt_position=int(row["shape_pt_position"]) if row.get("shape_pt_position") else None, + shape_dist_traveled=float(row["shape_dist_traveled"]) if row.get("shape_dist_traveled") else None, + ) + + if shape.shape_pt_lat is not None and shape.shape_pt_lon is not None: + shape_pt_25829_x, shape_pt_25829_y = transformer.transform( + shape.shape_pt_lon, shape.shape_pt_lat + ) + shape.shape_pt_25829_x = shape_pt_25829_x + shape.shape_pt_25829_y = shape_pt_25829_y + + if shape.shape_id not in shapes: + shapes[shape.shape_id] = [] + shapes[shape.shape_id].append(shape) + except Exception as e: + logger.warning( + f"Error parsing stops.txt line {row_num}: {e} - line data: {row}" + ) + except FileNotFoundError: + logger.error(f"File not found: {file_path}") + except Exception as e: + logger.error(f"Error reading stops.txt: {e}") + + + # Write shapes to Protobuf files + from src.proto.stop_schedule_pb2 import Epsg25829, Shape as PbShape + + for shape_id, shape_points in shapes.items(): + points = sorted(shape_points, key=lambda sp: sp.shape_pt_position if sp.shape_pt_position is not None else 0) + + pb_shape = PbShape( + shape_id=shape_id, + points=[ + Epsg25829(x=pt.shape_pt_25829_x, y=pt.shape_pt_25829_y) + for pt in points + if pt.shape_pt_25829_x is not None and pt.shape_pt_25829_y is not None + ], + ) + + shape_file_path = os.path.join(out_dir, "shapes", f"{shape_id}.pb") + os.makedirs(os.path.dirname(shape_file_path), exist_ok=True) + + try: + with open(shape_file_path, "wb") as f: + f.write(pb_shape.SerializeToString()) + logger.debug(f"Shape Protobuf written to: {shape_file_path}") + except Exception as e: + logger.error(f"Error writing shape Protobuf to {shape_file_path}: {e}") diff --git a/src/gtfs_perstop_report/src/stop_schedule_pb2.py b/src/gtfs_perstop_report/src/stop_schedule_pb2.py new file mode 100644 index 0000000..285b057 --- /dev/null +++ b/src/gtfs_perstop_report/src/stop_schedule_pb2.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: stop_schedule.proto +# Protobuf Python Version: 6.33.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 0, + '', + 'stop_schedule.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\xe3\x03\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\xe5\x02\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\tB$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' + _globals['_EPSG25829']._serialized_start=30 + _globals['_EPSG25829']._serialized_end=63 + _globals['_STOPARRIVALS']._serialized_start=66 + _globals['_STOPARRIVALS']._serialized_end=549 + _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_start=192 + _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_end=549 +# @@protoc_insertion_point(module_scope) diff --git a/src/gtfs_perstop_report/src/stop_schedule_pb2.pyi b/src/gtfs_perstop_report/src/stop_schedule_pb2.pyi new file mode 100644 index 0000000..aa42cdb --- /dev/null +++ b/src/gtfs_perstop_report/src/stop_schedule_pb2.pyi @@ -0,0 +1,60 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Epsg25829(_message.Message): + __slots__ = () + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> None: ... + +class StopArrivals(_message.Message): + __slots__ = () + class ScheduledArrival(_message.Message): + __slots__ = () + SERVICE_ID_FIELD_NUMBER: _ClassVar[int] + TRIP_ID_FIELD_NUMBER: _ClassVar[int] + LINE_FIELD_NUMBER: _ClassVar[int] + ROUTE_FIELD_NUMBER: _ClassVar[int] + SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + SHAPE_DIST_TRAVELED_FIELD_NUMBER: _ClassVar[int] + STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] + NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] + STARTING_CODE_FIELD_NUMBER: _ClassVar[int] + STARTING_NAME_FIELD_NUMBER: _ClassVar[int] + STARTING_TIME_FIELD_NUMBER: _ClassVar[int] + CALLING_TIME_FIELD_NUMBER: _ClassVar[int] + CALLING_SSM_FIELD_NUMBER: _ClassVar[int] + TERMINUS_CODE_FIELD_NUMBER: _ClassVar[int] + TERMINUS_NAME_FIELD_NUMBER: _ClassVar[int] + TERMINUS_TIME_FIELD_NUMBER: _ClassVar[int] + service_id: str + trip_id: str + line: str + route: str + shape_id: str + shape_dist_traveled: float + stop_sequence: int + next_streets: _containers.RepeatedScalarFieldContainer[str] + starting_code: str + starting_name: str + starting_time: str + calling_time: str + calling_ssm: int + terminus_code: str + terminus_name: str + terminus_time: str + def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ...) -> None: ... + STOP_ID_FIELD_NUMBER: _ClassVar[int] + LOCATION_FIELD_NUMBER: _ClassVar[int] + ARRIVALS_FIELD_NUMBER: _ClassVar[int] + stop_id: str + location: Epsg25829 + arrivals: _containers.RepeatedCompositeFieldContainer[StopArrivals.ScheduledArrival] + def __init__(self, stop_id: _Optional[str] = ..., location: _Optional[_Union[Epsg25829, _Mapping]] = ..., arrivals: _Optional[_Iterable[_Union[StopArrivals.ScheduledArrival, _Mapping]]] = ...) -> None: ... diff --git a/src/gtfs_perstop_report/src/stop_times.py b/src/gtfs_perstop_report/src/stop_times.py new file mode 100644 index 0000000..f3c3f25 --- /dev/null +++ b/src/gtfs_perstop_report/src/stop_times.py @@ -0,0 +1,120 @@ +""" +Functions for handling GTFS stop_times data. +""" +import csv +import os +from src.logger import get_logger + +logger = get_logger("stop_times") + + +STOP_TIMES_BY_FEED: dict[str, dict[str, list["StopTime"]]] = {} +STOP_TIMES_BY_REQUEST: dict[tuple[str, frozenset[str]], dict[str, list["StopTime"]]] = {} + +class StopTime: + """ + Class representing a stop time entry in the GTFS data. + """ + def __init__(self, trip_id: str, arrival_time: str, departure_time: str, stop_id: str, stop_sequence: int, shape_dist_traveled: float | None): + self.trip_id = trip_id + self.arrival_time = arrival_time + self.departure_time = departure_time + self.stop_id = stop_id + self.stop_sequence = stop_sequence + self.shape_dist_traveled = shape_dist_traveled + self.day_change = False # New attribute to indicate day change + + def __str__(self): + return f"StopTime({self.trip_id=}, {self.arrival_time=}, {self.departure_time=}, {self.stop_id=}, {self.stop_sequence=})" + + +def _load_stop_times_for_feed(feed_dir: str) -> dict[str, list[StopTime]]: + """Load and cache all stop_times for a feed directory.""" + if feed_dir in STOP_TIMES_BY_FEED: + return STOP_TIMES_BY_FEED[feed_dir] + + stops: dict[str, list[StopTime]] = {} + + try: + with open(os.path.join(feed_dir, 'stop_times.txt'), 'r', encoding="utf-8", newline='') as stop_times_file: + reader = csv.DictReader(stop_times_file) + if reader.fieldnames is None: + logger.error("stop_times.txt missing header row.") + STOP_TIMES_BY_FEED[feed_dir] = {} + return STOP_TIMES_BY_FEED[feed_dir] + + required_columns = ['trip_id', 'arrival_time', 'departure_time', 'stop_id', 'stop_sequence'] + missing_columns = [col for col in required_columns if col not in reader.fieldnames] + if missing_columns: + logger.error(f"Required columns not found in header: {missing_columns}") + STOP_TIMES_BY_FEED[feed_dir] = {} + return STOP_TIMES_BY_FEED[feed_dir] + + has_shape_dist = 'shape_dist_traveled' in reader.fieldnames + if not has_shape_dist: + logger.warning("Column 'shape_dist_traveled' not found in stop_times.txt. Distances will be set to None.") + + for row in reader: + trip_id = row['trip_id'] + if trip_id not in stops: + stops[trip_id] = [] + + dist = None + if has_shape_dist and row['shape_dist_traveled']: + try: + dist = float(row['shape_dist_traveled']) + except ValueError: + pass + + try: + stops[trip_id].append(StopTime( + trip_id=trip_id, + arrival_time=row['arrival_time'], + departure_time=row['departure_time'], + stop_id=row['stop_id'], + stop_sequence=int(row['stop_sequence']), + shape_dist_traveled=dist + )) + except ValueError as e: + logger.warning(f"Error parsing stop_sequence for trip {trip_id}: {e}") + + for trip_stop_times in stops.values(): + trip_stop_times.sort(key=lambda st: st.stop_sequence) + + except FileNotFoundError: + logger.warning("stop_times.txt file not found.") + stops = {} + + STOP_TIMES_BY_FEED[feed_dir] = stops + return stops + + +def get_stops_for_trips(feed_dir: str, trip_ids: list[str]) -> dict[str, list[StopTime]]: + """ + Get stops for a list of trip IDs based on the cached 'stop_times.txt' data. + """ + if not trip_ids: + return {} + + request_key = (feed_dir, frozenset(trip_ids)) + cached_subset = STOP_TIMES_BY_REQUEST.get(request_key) + if cached_subset is not None: + return cached_subset + + feed_cache = _load_stop_times_for_feed(feed_dir) + if not feed_cache: + STOP_TIMES_BY_REQUEST[request_key] = {} + return {} + + result: dict[str, list[StopTime]] = {} + seen: set[str] = set() + for trip_id in trip_ids: + if trip_id in seen: + continue + seen.add(trip_id) + trip_stop_times = feed_cache.get(trip_id) + if trip_stop_times: + result[trip_id] = trip_stop_times + + STOP_TIMES_BY_REQUEST[request_key] = result + return result diff --git a/src/gtfs_perstop_report/src/stops.py b/src/gtfs_perstop_report/src/stops.py new file mode 100644 index 0000000..bb54fa4 --- /dev/null +++ b/src/gtfs_perstop_report/src/stops.py @@ -0,0 +1,100 @@ +import csv +import os +from dataclasses import dataclass +from typing import Dict, Optional + +from pyproj import Transformer + +from src.logger import get_logger + +logger = get_logger("stops") + + +@dataclass +class Stop: + stop_id: str + stop_code: Optional[str] + stop_name: Optional[str] + stop_lat: Optional[float] + stop_lon: Optional[float] + + stop_25829_x: Optional[float] = None + stop_25829_y: Optional[float] = None + + +CACHED_STOPS: dict[str, dict[str, Stop]] = {} +CACHED_BY_CODE: dict[str, dict[str, Stop]] = {} + + +def get_all_stops_by_code(feed_dir: str) -> Dict[str, Stop]: + if feed_dir in CACHED_BY_CODE: + return CACHED_BY_CODE[feed_dir] + + transformer = Transformer.from_crs(4326, 25829, always_xy=True) + + stops_by_code: Dict[str, Stop] = {} + all_stops = get_all_stops(feed_dir) + + for stop in all_stops.values(): + stop_25829_x, stop_25829_y = transformer.transform( + stop.stop_lon, stop.stop_lat + ) + stop.stop_25829_x = stop_25829_x + stop.stop_25829_y = stop_25829_y + + if stop.stop_code: + stops_by_code[get_numeric_code(stop.stop_code)] = stop + else: + stops_by_code[stop.stop_id] = stop + + CACHED_BY_CODE[feed_dir] = stops_by_code + + return stops_by_code + + +def get_all_stops(feed_dir: str) -> Dict[str, Stop]: + if feed_dir in CACHED_STOPS: + return CACHED_STOPS[feed_dir] + + stops: Dict[str, Stop] = {} + file_path = os.path.join(feed_dir, "stops.txt") + + try: + with open(file_path, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f, quotechar='"', delimiter=",") + for row_num, row in enumerate(reader, start=2): + try: + stop = Stop( + stop_id=row["stop_id"], + stop_code=row.get("stop_code"), + stop_name=row["stop_name"].strip() + if row.get("stop_name", "").strip() + else row.get("stop_desc"), + stop_lat=float(row["stop_lat"]) + if row.get("stop_lat") + else None, + stop_lon=float(row["stop_lon"]) + if row.get("stop_lon") + else None, + ) + stops[stop.stop_id] = stop + except Exception as e: + logger.warning( + f"Error parsing stops.txt line {row_num}: {e} - line data: {row}" + ) + + except FileNotFoundError: + logger.error(f"File not found: {file_path}") + except Exception as e: + logger.error(f"Error reading stops.txt: {e}") + + CACHED_STOPS[feed_dir] = stops + + return stops + + +def get_numeric_code(stop_code: str | None) -> str: + if not stop_code: + return "" + numeric_code = "".join(c for c in stop_code if c.isdigit()) + return str(int(numeric_code)) if numeric_code else "" diff --git a/src/gtfs_perstop_report/src/street_name.py b/src/gtfs_perstop_report/src/street_name.py new file mode 100644 index 0000000..ec6b5b6 --- /dev/null +++ b/src/gtfs_perstop_report/src/street_name.py @@ -0,0 +1,49 @@ +import re + + +re_remove_quotation_marks = re.compile(r'[""”]', re.IGNORECASE) +re_anything_before_stopcharacters_with_parentheses = re.compile( + r'^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()', re.IGNORECASE) + + +NAME_REPLACEMENTS = { + "Rúa da Salguera Entrada": "Rúa da Salgueira", + "Rúa da Salgueira Entrada": "Rúa da Salgueira", + "Estrada de Miraflores": "Estrada Miraflores", + "FORA DE SERVIZO.G.B.": "", + "Praza de Fernando O Católico": "", + "Rúa da Travesía de Vigo": "Travesía de Vigo", + " de ": " ", + " do ": " ", + " da ": " ", + " das ": " ", + "Riós": "Ríos" +} + + +def get_street_name(original_name: str) -> str: + original_name = re.sub(re_remove_quotation_marks, + '', original_name).strip() + match = re.match( + re_anything_before_stopcharacters_with_parentheses, original_name) + if match: + street_name = match.group(1) + else: + street_name = original_name + + for old_name, new_name in NAME_REPLACEMENTS.items(): + if old_name.lower() in street_name.lower(): + street_name = street_name.replace(old_name, new_name) + return street_name.strip() + + return street_name + + +def normalise_stop_name(original_name: str | None) -> str: + if original_name is None: + return '' + stop_name = re.sub(re_remove_quotation_marks, '', original_name).strip() + + stop_name = stop_name.replace(' ', ', ') + + return stop_name diff --git a/src/gtfs_perstop_report/src/trips.py b/src/gtfs_perstop_report/src/trips.py new file mode 100644 index 0000000..0cedd26 --- /dev/null +++ b/src/gtfs_perstop_report/src/trips.py @@ -0,0 +1,134 @@ +""" +Functions for handling GTFS trip data. +""" +import os +from src.logger import get_logger + +logger = get_logger("trips") + +class TripLine: + """ + Class representing a trip line in the GTFS data. + """ + def __init__(self, route_id: str, service_id: str, trip_id: str, headsign: str, direction_id: int, shape_id: str|None = None, block_id: str|None = None): + self.route_id = route_id + self.service_id = service_id + self.trip_id = trip_id + self.headsign = headsign + self.direction_id = direction_id + self.shape_id = shape_id + self.block_id = block_id + self.route_short_name = "" + self.route_color = "" + + def __str__(self): + return f"TripLine({self.route_id=}, {self.service_id=}, {self.trip_id=}, {self.headsign=}, {self.direction_id=}, {self.shape_id=}, {self.block_id=})" + + +TRIPS_BY_SERVICE_ID: dict[str, dict[str, list[TripLine]]] = {} + + +def get_trips_for_services(feed_dir: str, service_ids: list[str]) -> dict[str, list[TripLine]]: + """ + Get trips for a list of service IDs based on the 'trips.txt' file. + Uses caching to avoid reading and parsing the file multiple times. + + Args: + feed_dir (str): Directory containing the GTFS feed files. + service_ids (list[str]): List of service IDs to find trips for. + + Returns: + dict[str, list[TripLine]]: Dictionary mapping service IDs to lists of trip objects. + """ + # Check if we already have cached data for this feed directory + if feed_dir in TRIPS_BY_SERVICE_ID: + logger.debug(f"Using cached trips data for {feed_dir}") + # Return only the trips for the requested service IDs + return {service_id: TRIPS_BY_SERVICE_ID[feed_dir].get(service_id, []) + for service_id in service_ids} + + trips: dict[str, list[TripLine]] = {} + + try: + with open(os.path.join(feed_dir, 'trips.txt'), 'r', encoding="utf-8") as trips_file: + lines = trips_file.readlines() + if len(lines) <= 1: + logger.warning( + "trips.txt file is empty or has only header line, not processing.") + return trips + + header = lines[0].strip().split(',') + try: + service_id_index = header.index('service_id') + trip_id_index = header.index('trip_id') + route_id_index = header.index('route_id') + headsign_index = header.index('trip_headsign') + direction_id_index = header.index('direction_id') + except ValueError as e: + logger.error(f"Required column not found in header: {e}") + return trips + + # Check if shape_id column exists + shape_id_index = None + if 'shape_id' in header: + shape_id_index = header.index('shape_id') + else: + logger.warning("shape_id column not found in trips.txt") + + # Check if block_id column exists + block_id_index = None + if 'block_id' in header: + block_id_index = header.index('block_id') + else: + logger.info("block_id column not found in trips.txt") + + # Initialize cache for this feed directory + TRIPS_BY_SERVICE_ID[feed_dir] = {} + + for line in lines[1:]: + parts = line.strip().split(',') + if len(parts) < len(header): + logger.warning( + f"Skipping malformed line in trips.txt: {line.strip()}") + continue + + service_id = parts[service_id_index] + trip_id = parts[trip_id_index] + + # Cache all trips, not just the ones requested + if service_id not in TRIPS_BY_SERVICE_ID[feed_dir]: + TRIPS_BY_SERVICE_ID[feed_dir][service_id] = [] + + # Get shape_id if available + shape_id = None + if shape_id_index is not None and shape_id_index < len(parts): + shape_id = parts[shape_id_index] if parts[shape_id_index] else None + + # Get block_id if available + block_id = None + if block_id_index is not None and block_id_index < len(parts): + block_id = parts[block_id_index] if parts[block_id_index] else None + + trip_line = TripLine( + route_id=parts[route_id_index], + service_id=service_id, + trip_id=trip_id, + headsign=parts[headsign_index], + direction_id=int( + parts[direction_id_index] if parts[direction_id_index] else -1), + shape_id=shape_id, + block_id=block_id + ) + + TRIPS_BY_SERVICE_ID[feed_dir][service_id].append(trip_line) + + # Also build the result for the requested service IDs + if service_id in service_ids: + if service_id not in trips: + trips[service_id] = [] + trips[service_id].append(trip_line) + + except FileNotFoundError: + logger.warning("trips.txt file not found.") + + return trips diff --git a/src/gtfs_perstop_report/stop_report.py b/src/gtfs_perstop_report/stop_report.py new file mode 100644 index 0000000..f8fdc64 --- /dev/null +++ b/src/gtfs_perstop_report/stop_report.py @@ -0,0 +1,666 @@ +import argparse +import os +import shutil +import sys +import time +import traceback +from typing import Any, Dict, List, Optional, Tuple + +from src.shapes import process_shapes +from src.common import get_all_feed_dates +from src.download import download_feed_from_url +from src.logger import get_logger +from src.report_writer import write_stop_json, write_stop_protobuf +from src.routes import load_routes +from src.services import get_active_services +from src.stop_times import get_stops_for_trips, StopTime +from src.stops import get_all_stops, get_all_stops_by_code, get_numeric_code +from src.street_name import normalise_stop_name +from src.trips import get_trips_for_services, TripLine +from src.providers import get_provider + +logger = get_logger("stop_report") + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Generate stop-based JSON reports for a date or date range." + ) + parser.add_argument( + "--output-dir", + type=str, + default="./output/", + help="Directory to write reports to (default: ./output/)", + ) + parser.add_argument("--feed-dir", type=str, + help="Path to the feed directory") + parser.add_argument( + "--feed-url", + type=str, + help="URL to download the GTFS feed from (if not using local feed directory)", + ) + parser.add_argument( + "--force-download", + action="store_true", + help="Force download even if the feed hasn't been modified (only applies when using --feed-url)", + ) + parser.add_argument( + "--provider", + type=str, + default="default", + help="Feed provider type (vitrasa, renfe, default). Default: default", + ) + args = parser.parse_args() + + if args.feed_dir and args.feed_url: + parser.error("Specify either --feed-dir or --feed-url, not both.") + if not args.feed_dir and not args.feed_url: + parser.error( + "You must specify either a path to the existing feed (unzipped) or a URL to download the GTFS feed from." + ) + if args.feed_dir and not os.path.exists(args.feed_dir): + parser.error(f"Feed directory does not exist: {args.feed_dir}") + return args + + +def time_to_seconds(time_str: str) -> int: + """ + Convert HH:MM:SS to seconds since midnight. + Handles GTFS times that can exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day). + """ + if not time_str: + return 0 + + parts = time_str.split(":") + if len(parts) != 3: + return 0 + + try: + hours, minutes, seconds = map(int, parts) + return hours * 3600 + minutes * 60 + seconds + except ValueError: + return 0 + + +def normalize_gtfs_time(time_str: str) -> str: + """ + Normalize GTFS time format to standard HH:MM:SS (0-23 hours). + Converts times like 25:30:00 to 01:30:00. + + Args: + time_str: Time in HH:MM:SS format, possibly with hours >= 24 + + Returns: + Normalized time string in HH:MM:SS format + """ + if not time_str: + return time_str + + parts = time_str.split(":") + if len(parts) != 3: + return time_str + + try: + hours, minutes, seconds = map(int, parts) + normalized_hours = hours % 24 + return f"{normalized_hours:02d}:{minutes:02d}:{seconds:02d}" + except ValueError: + return time_str + + +def format_gtfs_time(time_str: str) -> str: + """ + Format GTFS time to HH:MM:SS, preserving hours >= 24. + """ + if not time_str: + return time_str + + parts = time_str.split(":") + if len(parts) != 3: + return time_str + + try: + hours, minutes, seconds = map(int, parts) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + except ValueError: + return time_str + + +def is_next_day_service(time_str: str) -> bool: + """ + Check if a GTFS time represents a service on the next day (hours >= 24). + + Args: + time_str: Time in HH:MM:SS format + + Returns: + True if the time is >= 24:00:00, False otherwise + """ + if not time_str: + return False + + parts = time_str.split(":") + if len(parts) != 3: + return False + + try: + hours = int(parts[0]) + return hours >= 24 + except ValueError: + return False + + +def parse_trip_id_components(trip_id: str) -> Optional[Tuple[str, str, int]]: + """ + Parse a trip ID in format XXXYYY-Z or XXXYYY_Z where: + - XXX = line number (e.g., 003) + - YYY = shift/internal ID (e.g., 001) + - Z = trip number (e.g., 12) + + Supported formats: + 1. ..._XXXYYY_Z (e.g. "C1 01SNA00_001001_18") + 2. ..._XXXYYY-Z (e.g. "VIGO_20241122_003001-12") + + Returns tuple of (line, shift_id, trip_number) or None if parsing fails. + """ + try: + parts = trip_id.split("_") + if len(parts) < 2: + return None + + # Try format 1: ..._XXXYYY_Z + # Check if second to last part is 6 digits (XXXYYY) and last part is numeric + if len(parts) >= 2: + shift_part = parts[-2] + trip_num_str = parts[-1] + if len(shift_part) == 6 and shift_part.isdigit() and trip_num_str.isdigit(): + line = shift_part[:3] + shift_id = shift_part[3:6] + trip_number = int(trip_num_str) + return (line, shift_id, trip_number) + + # Try format 2: ..._XXXYYY-Z + # The trip ID is the last part in format XXXYYY-Z + trip_part = parts[-1] + + if "-" in trip_part: + shift_part, trip_num_str = trip_part.split("-", 1) + + # shift_part should be 6 digits: XXXYYY + if len(shift_part) == 6 and shift_part.isdigit(): + line = shift_part[:3] # First 3 digits + shift_id = shift_part[3:6] # Next 3 digits + trip_number = int(trip_num_str) + return (line, shift_id, trip_number) + + return None + except (ValueError, IndexError): + return None + + +def build_trip_previous_shape_map( + trips: Dict[str, List[TripLine]], + stops_for_all_trips: Dict[str, List[StopTime]], +) -> Dict[str, Optional[str]]: + """ + Build a mapping from trip_id to previous_trip_shape_id. + + Links trips based on trip ID structure (XXXYYY-Z) where trips with the same + XXX (line) and YYY (shift) and sequential Z (trip numbers) are connected + if the terminus of trip N matches the start of trip N+1. + + Args: + trips: Dictionary of service_id -> list of trips + stops_for_all_trips: Dictionary of trip_id -> list of stop times + + Returns: + Dictionary mapping trip_id to previous_trip_shape_id (or None) + """ + trip_previous_shape: Dict[str, Optional[str]] = {} + + # Collect all trips across all services + all_trips_list: List[TripLine] = [] + for trip_list in trips.values(): + all_trips_list.extend(trip_list) + + # Group trips by shift ID (line + shift combination) + trips_by_shift: Dict[str, List[Tuple[TripLine, int, str, str]]] = {} + + for trip in all_trips_list: + parsed = parse_trip_id_components(trip.trip_id) + if not parsed: + continue + + line, shift_id, trip_number = parsed + shift_key = f"{line}{shift_id}" + + trip_stops = stops_for_all_trips.get(trip.trip_id) + if not trip_stops or len(trip_stops) < 2: + continue + + first_stop = trip_stops[0] + last_stop = trip_stops[-1] + + if shift_key not in trips_by_shift: + trips_by_shift[shift_key] = [] + + trips_by_shift[shift_key].append(( + trip, + trip_number, + first_stop.stop_id, + last_stop.stop_id + )) + # For each shift, sort trips by trip number and link consecutive trips + for shift_key, shift_trips in trips_by_shift.items(): + # Sort by trip number + shift_trips.sort(key=lambda x: x[1]) + # Link consecutive trips if their stops match + for i in range(1, len(shift_trips)): + current_trip, current_num, current_start_stop, _ = shift_trips[i] + prev_trip, prev_num, _, prev_end_stop = shift_trips[i - 1] + + # Check if trips are consecutive (trip numbers differ by 1), + # if previous trip's terminus matches current trip's start, + # and if both trips have valid shape IDs + if (current_num == prev_num + 1 and + prev_end_stop == current_start_stop and + prev_trip.shape_id and + current_trip.shape_id): + trip_previous_shape[current_trip.trip_id] = prev_trip.shape_id + + return trip_previous_shape + + +def get_stop_arrivals(feed_dir: str, date: str, provider) -> Dict[str, List[Dict[str, Any]]]: + """ + Process trips for the given date and organize stop arrivals. + Also includes night services from the previous day (times >= 24:00:00). + + Args: + feed_dir: Path to the GTFS feed directory + date: Date in YYYY-MM-DD format + provider: Provider class with feed-specific formatting methods + + Returns: + Dictionary mapping stop_code to lists of arrival information. + """ + from datetime import datetime, timedelta + + stops = get_all_stops(feed_dir) + logger.info(f"Found {len(stops)} stops in the feed.") + + active_services = get_active_services(feed_dir, date) + if not active_services: + logger.info("No active services found for the given date.") + + logger.info( + f"Found {len(active_services)} active services for date {date}.") + + # Also get services from the previous day to include night services (times >= 24:00) + prev_date = (datetime.strptime(date, "%Y-%m-%d") - + timedelta(days=1)).strftime("%Y-%m-%d") + prev_services = get_active_services(feed_dir, prev_date) + logger.info( + f"Found {len(prev_services)} active services for previous date {prev_date} (for night services).") + + all_services = list(set(active_services + prev_services)) + + if not all_services: + logger.info("No active services found for current or previous date.") + return {} + + trips = get_trips_for_services(feed_dir, all_services) + total_trip_count = sum(len(trip_list) for trip_list in trips.values()) + logger.info(f"Found {total_trip_count} trips for active services.") + + # Get all trip IDs + all_trip_ids = [trip.trip_id for trip_list in trips.values() + for trip in trip_list] + + # Get stops for all trips + stops_for_all_trips = get_stops_for_trips(feed_dir, all_trip_ids) + logger.info(f"Precomputed stops for {len(stops_for_all_trips)} trips.") + + # Build mapping from trip_id to previous trip's shape_id + trip_previous_shape_map = build_trip_previous_shape_map( + trips, stops_for_all_trips) + logger.info( + f"Built previous trip shape mapping for {len(trip_previous_shape_map)} trips.") + + # Load routes information + routes = load_routes(feed_dir) + logger.info(f"Loaded {len(routes)} routes from feed.") + + # Create a reverse lookup from stop_id to stop_code (or stop_id as fallback) + stop_id_to_code = {} + for stop_id, stop in stops.items(): + if stop.stop_code: + stop_id_to_code[stop_id] = get_numeric_code(stop.stop_code) + else: + # Fallback to stop_id if stop_code is not available (e.g., train stations) + stop_id_to_code[stop_id] = stop_id + + # Organize data by stop_code + stop_arrivals = {} + + active_services_set = set(active_services) + prev_services_set = set(prev_services) + + for service_id, trip_list in trips.items(): + is_active = service_id in active_services_set + is_prev = service_id in prev_services_set + + if not is_active and not is_prev: + continue + + for trip in trip_list: + # Get route information once per trip + route_info = routes.get(trip.route_id, {}) + route_short_name = route_info.get("route_short_name", "") + trip_headsign = getattr(trip, "headsign", "") or "" + trip_id = trip.trip_id + + # Get stop times for this trip + trip_stops = stops_for_all_trips.get(trip.trip_id, []) + if not trip_stops: + continue + + # Pair stop_times with stop metadata once to avoid repeated lookups + trip_stop_pairs = [] + stop_names = [] + for stop_time in trip_stops: + stop = stops.get(stop_time.stop_id) + trip_stop_pairs.append((stop_time, stop)) + stop_names.append(stop.stop_name if stop else "Unknown Stop") + + # Memoize street names per stop name for this trip and build segments + street_cache: dict[str, str] = {} + segment_names: list[str] = [] + stop_to_segment_idx: list[int] = [] + previous_street: str | None = None + for name in stop_names: + street = street_cache.get(name) + if street is None: + street = provider.extract_street_name(name) + street_cache[name] = street + if street != previous_street: + segment_names.append(street) + previous_street = street + stop_to_segment_idx.append(len(segment_names) - 1) + + # Precompute future street transitions per segment + future_suffix_by_segment: list[tuple[str, ...]] = [ + ()] * len(segment_names) + future_tuple: tuple[str, ...] = () + for idx in range(len(segment_names) - 1, -1, -1): + future_suffix_by_segment[idx] = future_tuple + current_street = segment_names[idx] + future_tuple = ( + (current_street,) + future_tuple + if current_street is not None + else future_tuple + ) + + segment_future_lists: dict[int, list[str]] = {} + + first_stop_time, first_stop = trip_stop_pairs[0] + last_stop_time, last_stop = trip_stop_pairs[-1] + + starting_stop_name = first_stop.stop_name if first_stop else "Unknown Stop" + terminus_stop_name = last_stop.stop_name if last_stop else "Unknown Stop" + + # Get stop codes with fallback to stop_id if stop_code is empty + if first_stop: + starting_code = get_numeric_code(first_stop.stop_code) + if not starting_code: + starting_code = first_stop_time.stop_id + else: + starting_code = "" + + if last_stop: + terminus_code = get_numeric_code(last_stop.stop_code) + if not terminus_code: + terminus_code = last_stop_time.stop_id + else: + terminus_code = "" + + starting_name = normalise_stop_name(starting_stop_name) + terminus_name = normalise_stop_name(terminus_stop_name) + starting_time = first_stop_time.departure_time + terminus_time = last_stop_time.arrival_time + + # Determine processing passes for this trip + passes = [] + if is_active: + passes.append("current") + if is_prev: + passes.append("previous") + + for mode in passes: + is_current_mode = (mode == "current") + + for i, (stop_time, _) in enumerate(trip_stop_pairs): + # Skip the last stop of the trip (terminus) to avoid duplication + if i == len(trip_stop_pairs) - 1: + continue + + stop_code = stop_id_to_code.get(stop_time.stop_id) + + if not stop_code: + continue # Skip stops without a code + + dep_time = stop_time.departure_time + + if not is_current_mode: + # Previous day service: only include if calling_time >= 24:00:00 (night services rolling to this day) + if not is_next_day_service(dep_time): + continue + + # Normalize times for display on current day (e.g. 25:30 -> 01:30) + final_starting_time = normalize_gtfs_time( + starting_time) + final_calling_time = normalize_gtfs_time(dep_time) + final_terminus_time = normalize_gtfs_time( + terminus_time) + # SSM should be small (early morning) + final_calling_ssm = time_to_seconds(final_calling_time) + else: + # Current day service: include ALL times + # Keep times as is (e.g. 25:30 stays 25:30) + final_starting_time = format_gtfs_time(starting_time) + final_calling_time = format_gtfs_time(dep_time) + final_terminus_time = format_gtfs_time(terminus_time) + # SSM should be large if > 24:00 + final_calling_ssm = time_to_seconds(dep_time) + + if stop_code not in stop_arrivals: + stop_arrivals[stop_code] = [] + + if segment_names: + segment_idx = stop_to_segment_idx[i] + if segment_idx not in segment_future_lists: + segment_future_lists[segment_idx] = list( + future_suffix_by_segment[segment_idx] + ) + next_streets = segment_future_lists[segment_idx].copy() + else: + next_streets = [] + + # Format IDs and route using provider-specific logic + service_id_fmt = provider.format_service_id(service_id) + trip_id_fmt = provider.format_trip_id(trip_id) + route_fmt = provider.format_route( + trip_headsign, terminus_name) + + # Get previous trip shape_id if available + previous_trip_shape_id = trip_previous_shape_map.get( + trip_id, "") + + stop_arrivals[stop_code].append( + { + "service_id": service_id_fmt, + "trip_id": trip_id_fmt, + "line": route_short_name, + "route": route_fmt, + "shape_id": getattr(trip, "shape_id", ""), + "stop_sequence": stop_time.stop_sequence, + "shape_dist_traveled": getattr( + stop_time, "shape_dist_traveled", 0 + ), + "next_streets": [s for s in next_streets if s != ""], + "starting_code": starting_code, + "starting_name": starting_name, + "starting_time": final_starting_time, + "calling_time": final_calling_time, + "calling_ssm": final_calling_ssm, + "terminus_code": terminus_code, + "terminus_name": terminus_name, + "terminus_time": final_terminus_time, + "previous_trip_shape_id": previous_trip_shape_id, + } + ) + + # Sort each stop's arrivals by arrival time + for stop_code in stop_arrivals: + # Filter out entries with None arrival_seconds + stop_arrivals[stop_code] = [ + item for item in stop_arrivals[stop_code] if item["calling_ssm"] is not None + ] + stop_arrivals[stop_code].sort(key=lambda x: x["calling_ssm"]) + + return stop_arrivals + + +def process_date( + feed_dir: str, date: str, output_dir: str, provider +) -> tuple[str, Dict[str, int]]: + """ + Process a single date and write its stop JSON files. + Returns summary data for index generation. + """ + logger = get_logger(f"stop_report_{date}") + try: + logger.info(f"Starting stop report generation for date {date}") + + stops_by_code = get_all_stops_by_code(feed_dir) + + # Get all stop arrivals for the current date + stop_arrivals = get_stop_arrivals(feed_dir, date, provider) + + if not stop_arrivals: + logger.warning(f"No stop arrivals found for date {date}") + return date, {} + + logger.info( + f"Writing stop reports for {len(stop_arrivals)} stops for date {date}" + ) + + # Write individual stop JSON files + writing_start_time = time.perf_counter() + for stop_code, arrivals in stop_arrivals.items(): + write_stop_json(output_dir, date, stop_code, arrivals) + writing_end_time = time.perf_counter() + writing_elapsed = writing_end_time - writing_start_time + + logger.info( + f"Finished writing stop JSON reports for date {date} in {writing_elapsed:.2f}s" + ) + + # Write individual stop JSON files + writing_start_time = time.perf_counter() + for stop_code, arrivals in stop_arrivals.items(): + stop_by_code = stops_by_code.get(stop_code) + + if stop_by_code is not None: + write_stop_protobuf( + output_dir, + date, + stop_code, + arrivals, + stop_by_code.stop_25829_x or 0.0, + stop_by_code.stop_25829_y or 0.0, + ) + + writing_end_time = time.perf_counter() + writing_elapsed = writing_end_time - writing_start_time + + logger.info( + f"Finished writing stop protobuf reports for date {date} in {writing_elapsed:.2f}s" + ) + + logger.info(f"Processed {len(stop_arrivals)} stops for date {date}") + + stop_summary = { + stop_code: len(arrivals) for stop_code, arrivals in stop_arrivals.items() + } + return date, stop_summary + except Exception as e: + logger.error(f"Error processing date {date}: {e}") + raise + + +def main(): + args = parse_args() + output_dir = args.output_dir + feed_url = args.feed_url + + # Get provider configuration + try: + provider = get_provider(args.provider) + logger.info(f"Using provider: {args.provider}") + except ValueError as e: + logger.error(str(e)) + sys.exit(1) + + if not feed_url: + feed_dir = args.feed_dir + else: + logger.info(f"Downloading GTFS feed from {feed_url}...") + feed_dir = download_feed_from_url( + feed_url, output_dir, args.force_download) + if feed_dir is None: + logger.info("Download was skipped (feed not modified). Exiting.") + return + + all_dates = get_all_feed_dates(feed_dir) + if not all_dates: + logger.error("No valid dates found in feed.") + return + date_list = all_dates + + # Ensure date_list is not empty before processing + if not date_list: + logger.error("No valid dates to process.") + return + + logger.info(f"Processing {len(date_list)} dates") + + # Dictionary to store summary data for index files + all_stops_summary = {} + + for date in date_list: + _, stop_summary = process_date(feed_dir, date, output_dir, provider) + all_stops_summary[date] = stop_summary + + logger.info( + "Finished processing all dates. Beginning with shape transformation.") + + # Process shapes, converting each coordinate to EPSG:25829 and saving as Protobuf + process_shapes(feed_dir, output_dir) + + logger.info("Finished processing shapes.") + + if feed_url: + if os.path.exists(feed_dir): + shutil.rmtree(feed_dir) + logger.info(f"Removed temporary feed directory: {feed_dir}") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + logger = get_logger("stop_report") + logger.critical(f"An unexpected error occurred: {e}", exc_info=True) + traceback.print_exc() + sys.exit(1) diff --git a/src/gtfs_perstop_report/uv.lock b/src/gtfs_perstop_report/uv.lock new file mode 100644 index 0000000..bf7b7bd --- /dev/null +++ b/src/gtfs_perstop_report/uv.lock @@ -0,0 +1,253 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "gtfs-vigo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "colorama" }, + { name = "jinja2" }, + { name = "protobuf" }, + { name = "pyproj" }, + { name = "pytest" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "colorama", specifier = ">=0.4.6" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "protobuf", specifier = ">=5.29.1" }, + { name = "pyproj", specifier = ">=3.7.2" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "requests", specifier = ">=2.32.3" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432, upload-time = "2025-11-13T16:44:18.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593, upload-time = "2025-11-13T16:44:06.275Z" }, + { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883, upload-time = "2025-11-13T16:44:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522, upload-time = "2025-11-13T16:44:10.475Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445, upload-time = "2025-11-13T16:44:11.869Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161, upload-time = "2025-11-13T16:44:12.778Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171, upload-time = "2025-11-13T16:44:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyproj" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] diff --git a/src/gtfs_vigo_stops/.gitignore b/src/gtfs_vigo_stops/.gitignore deleted file mode 100644 index 2be2c5f..0000000 --- a/src/gtfs_vigo_stops/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -feed/ -output/ - -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv diff --git a/src/gtfs_vigo_stops/pyproject.toml b/src/gtfs_vigo_stops/pyproject.toml deleted file mode 100644 index 97d24a3..0000000 --- a/src/gtfs_vigo_stops/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[project] -name = "gtfs-vigo" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.13" -dependencies = [ - "colorama>=0.4.6", - "jinja2>=3.1.6", - "protobuf>=5.29.1", - "pyproj>=3.7.2", - "pytest>=8.4.1", - "requests>=2.32.3", -] - -[tool.ruff] -line-length = 88 -target-version = "py313" - -[tool.ruff.lint] -select = ["E", "F", "I", "W"] -ignore = [] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" - diff --git a/src/gtfs_vigo_stops/src/__init__.py b/src/gtfs_vigo_stops/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/gtfs_vigo_stops/src/common.py b/src/gtfs_vigo_stops/src/common.py deleted file mode 100644 index fcf93d5..0000000 --- a/src/gtfs_vigo_stops/src/common.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Common utilities for GTFS report generation. -""" - -import csv -import os -from datetime import datetime, timedelta -from typing import List - - -def get_all_feed_dates(feed_dir: str) -> List[str]: - """ - Returns all dates the feed is valid for, using calendar.txt if present, else calendar_dates.txt. - """ - calendar_path = os.path.join(feed_dir, "calendar.txt") - calendar_dates_path = os.path.join(feed_dir, "calendar_dates.txt") - - # Try calendar.txt first - if os.path.exists(calendar_path): - with open(calendar_path, encoding="utf-8") as f: - reader = csv.DictReader(f) - start_dates: List[str] = [] - end_dates: List[str] = [] - for row in reader: - if row.get("start_date") and row.get("end_date"): - start_dates.append(row["start_date"]) - end_dates.append(row["end_date"]) - if start_dates and end_dates: - min_date = min(start_dates) - max_date = max(end_dates) - # Convert YYYYMMDD to YYYY-MM-DD - start = datetime.strptime(min_date, "%Y%m%d") - end = datetime.strptime(max_date, "%Y%m%d") - result: List[str] = [] - while start <= end: - result.append(start.strftime("%Y-%m-%d")) - start += timedelta(days=1) - return result - else: - # Return from today to 7 days ahead if no valid dates found - today = datetime.now() - return [(today + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(8)] - - # Fallback: use calendar_dates.txt - if os.path.exists(calendar_dates_path): - with open(calendar_dates_path, encoding="utf-8") as f: - reader = csv.DictReader(f) - dates: set[str] = set() - for row in reader: - if row.get("exception_type") == "1" and row.get("date"): - # Convert YYYYMMDD to YYYY-MM-DD - d = row["date"] - dates.add(f"{d[:4]}-{d[4:6]}-{d[6:]}") - return sorted(dates) - - return [] - - -def time_to_seconds(time_str: str) -> int: - """Convert HH:MM:SS to seconds since midnight.""" - parts = time_str.split(":") - if len(parts) != 3: - return 0 - hours, minutes, seconds = map(int, parts) - return hours * 3600 + minutes * 60 + seconds diff --git a/src/gtfs_vigo_stops/src/download.py b/src/gtfs_vigo_stops/src/download.py deleted file mode 100644 index 19125bc..0000000 --- a/src/gtfs_vigo_stops/src/download.py +++ /dev/null @@ -1,130 +0,0 @@ -import os -import tempfile -import zipfile -import requests -import json -from typing import Optional, Tuple - -from src.logger import get_logger - -logger = get_logger("download") - -def _get_metadata_path(output_dir: str) -> str: - """Get the path to the metadata file for storing ETag and Last-Modified info.""" - return os.path.join(output_dir, '.gtfsmetadata') - -def _load_metadata(output_dir: str) -> Optional[dict]: - """Load existing metadata from the output directory.""" - metadata_path = _get_metadata_path(output_dir) - if os.path.exists(metadata_path): - try: - with open(metadata_path, 'r', encoding='utf-8') as f: - return json.load(f) - except (json.JSONDecodeError, IOError) as e: - logger.warning(f"Failed to load metadata from {metadata_path}: {e}") - return None - -def _save_metadata(output_dir: str, etag: Optional[str], last_modified: Optional[str]) -> None: - """Save ETag and Last-Modified metadata to the output directory.""" - metadata_path = _get_metadata_path(output_dir) - metadata = { - 'etag': etag, - 'last_modified': last_modified - } - - # Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) - - try: - with open(metadata_path, 'w', encoding='utf-8') as f: - json.dump(metadata, f, indent=2) - except IOError as e: - logger.warning(f"Failed to save metadata to {metadata_path}: {e}") - -def _check_if_modified(feed_url: str, output_dir: str) -> Tuple[bool, Optional[str], Optional[str]]: - """ - Check if the feed has been modified using conditional headers. - Returns (is_modified, etag, last_modified) - """ - metadata = _load_metadata(output_dir) - if not metadata: - return True, None, None - - headers = {} - if metadata.get('etag'): - headers['If-None-Match'] = metadata['etag'] - if metadata.get('last_modified'): - headers['If-Modified-Since'] = metadata['last_modified'] - - if not headers: - return True, None, None - - try: - response = requests.head(feed_url, headers=headers) - - if response.status_code == 304: - logger.info("Feed has not been modified (304 Not Modified), skipping download") - return False, metadata.get('etag'), metadata.get('last_modified') - elif response.status_code == 200: - etag = response.headers.get('ETag') - last_modified = response.headers.get('Last-Modified') - return True, etag, last_modified - else: - logger.warning(f"Unexpected response status {response.status_code} when checking for modifications, proceeding with download") - return True, None, None - except requests.RequestException as e: - logger.warning(f"Failed to check if feed has been modified: {e}, proceeding with download") - return True, None, None - -def download_feed_from_url(feed_url: str, output_dir: str = None, force_download: bool = False) -> Optional[str]: - """ - Download GTFS feed from URL. - - Args: - feed_url: URL to download the GTFS feed from - output_dir: Directory where reports will be written (used for metadata storage) - force_download: If True, skip conditional download checks - - Returns: - Path to the directory containing the extracted GTFS files, or None if download was skipped - """ - - # Check if we need to download the feed - if not force_download and output_dir: - is_modified, cached_etag, cached_last_modified = _check_if_modified(feed_url, output_dir) - if not is_modified: - logger.info("Feed has not been modified, skipping download") - return None - - # Create a directory in the system temporary directory - temp_dir = tempfile.mkdtemp(prefix='gtfs_vigo_') - - # Create a temporary zip file in the temporary directory - zip_filename = os.path.join(temp_dir, 'gtfs_vigo.zip') - - headers = {} - response = requests.get(feed_url, headers=headers) - - if response.status_code != 200: - raise Exception(f"Failed to download GTFS data: {response.status_code}") - - with open(zip_filename, 'wb') as file: - file.write(response.content) - - # Extract and save metadata if output_dir is provided - if output_dir: - etag = response.headers.get('ETag') - last_modified = response.headers.get('Last-Modified') - if etag or last_modified: - _save_metadata(output_dir, etag, last_modified) - - # Extract the zip file - with zipfile.ZipFile(zip_filename, 'r') as zip_ref: - zip_ref.extractall(temp_dir) - - # Clean up the downloaded zip file - os.remove(zip_filename) - - logger.info(f"GTFS feed downloaded from {feed_url} and extracted to {temp_dir}") - - return temp_dir \ No newline at end of file diff --git a/src/gtfs_vigo_stops/src/logger.py b/src/gtfs_vigo_stops/src/logger.py deleted file mode 100644 index 9488076..0000000 --- a/src/gtfs_vigo_stops/src/logger.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Logging configuration for the GTFS application. -""" -import logging -from colorama import init, Fore, Style - -# Initialize Colorama (required on Windows) -init(autoreset=True) - -class ColorFormatter(logging.Formatter): - def format(self, record: logging.LogRecord): - # Base format - log_format = "%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s" - - # Apply colors based on log level - if record.levelno == logging.DEBUG: - prefix = Style.DIM + Fore.WHITE # "Dark grey" - elif record.levelno == logging.INFO: - prefix = Fore.CYAN - elif record.levelno == logging.WARNING: - prefix = Fore.YELLOW - elif record.levelno == logging.ERROR: - prefix = Fore.RED - elif record.levelno == logging.CRITICAL: - prefix = Style.BRIGHT + Fore.RED - else: - prefix = "" - - # Add color to the entire line - formatter = logging.Formatter( - prefix + log_format + Style.RESET_ALL, "%Y-%m-%d %H:%M:%S") - return formatter.format(record) - -def get_logger(name: str) -> logging.Logger: - """ - Create and return a logger with the given name. - - Args: - name (str): The name of the logger. - - Returns: - logging.Logger: Configured logger instance. - """ - logger = logging.getLogger(name) - logger.setLevel(logging.INFO) - - # Only add handler if it doesn't already have one - if not logger.handlers: - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.DEBUG) - console_handler.setFormatter(ColorFormatter()) - logger.addHandler(console_handler) - - return logger diff --git a/src/gtfs_vigo_stops/src/proto/__init__.py b/src/gtfs_vigo_stops/src/proto/__init__.py deleted file mode 100644 index b775c17..0000000 --- a/src/gtfs_vigo_stops/src/proto/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Protobuf generated files diff --git a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py deleted file mode 100644 index cb4f336..0000000 --- a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: stop_schedule.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\x83\x04\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\x85\x03\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\t\x12\x1e\n\x16previous_trip_shape_id\x18\x33 \x01(\t\";\n\x05Shape\x12\x10\n\x08shape_id\x18\x01 \x01(\t\x12 \n\x06points\x18\x03 \x03(\x0b\x32\x10.proto.Epsg25829B$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' - _EPSG25829._serialized_start=30 - _EPSG25829._serialized_end=63 - _STOPARRIVALS._serialized_start=66 - _STOPARRIVALS._serialized_end=581 - _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_start=192 - _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_end=581 - _SHAPE._serialized_start=583 - _SHAPE._serialized_end=642 -# @@protoc_insertion_point(module_scope) diff --git a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi deleted file mode 100644 index 355798f..0000000 --- a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi +++ /dev/null @@ -1,69 +0,0 @@ -from google.protobuf.internal import containers as _containers -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class Epsg25829(_message.Message): - __slots__ = ["x", "y"] - X_FIELD_NUMBER: _ClassVar[int] - Y_FIELD_NUMBER: _ClassVar[int] - x: float - y: float - def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> None: ... - -class Shape(_message.Message): - __slots__ = ["points", "shape_id"] - POINTS_FIELD_NUMBER: _ClassVar[int] - SHAPE_ID_FIELD_NUMBER: _ClassVar[int] - points: _containers.RepeatedCompositeFieldContainer[Epsg25829] - shape_id: str - def __init__(self, shape_id: _Optional[str] = ..., points: _Optional[_Iterable[_Union[Epsg25829, _Mapping]]] = ...) -> None: ... - -class StopArrivals(_message.Message): - __slots__ = ["arrivals", "location", "stop_id"] - class ScheduledArrival(_message.Message): - __slots__ = ["calling_ssm", "calling_time", "line", "next_streets", "previous_trip_shape_id", "route", "service_id", "shape_dist_traveled", "shape_id", "starting_code", "starting_name", "starting_time", "stop_sequence", "terminus_code", "terminus_name", "terminus_time", "trip_id"] - CALLING_SSM_FIELD_NUMBER: _ClassVar[int] - CALLING_TIME_FIELD_NUMBER: _ClassVar[int] - LINE_FIELD_NUMBER: _ClassVar[int] - NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] - PREVIOUS_TRIP_SHAPE_ID_FIELD_NUMBER: _ClassVar[int] - ROUTE_FIELD_NUMBER: _ClassVar[int] - SERVICE_ID_FIELD_NUMBER: _ClassVar[int] - SHAPE_DIST_TRAVELED_FIELD_NUMBER: _ClassVar[int] - SHAPE_ID_FIELD_NUMBER: _ClassVar[int] - STARTING_CODE_FIELD_NUMBER: _ClassVar[int] - STARTING_NAME_FIELD_NUMBER: _ClassVar[int] - STARTING_TIME_FIELD_NUMBER: _ClassVar[int] - STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] - TERMINUS_CODE_FIELD_NUMBER: _ClassVar[int] - TERMINUS_NAME_FIELD_NUMBER: _ClassVar[int] - TERMINUS_TIME_FIELD_NUMBER: _ClassVar[int] - TRIP_ID_FIELD_NUMBER: _ClassVar[int] - calling_ssm: int - calling_time: str - line: str - next_streets: _containers.RepeatedScalarFieldContainer[str] - previous_trip_shape_id: str - route: str - service_id: str - shape_dist_traveled: float - shape_id: str - starting_code: str - starting_name: str - starting_time: str - stop_sequence: int - terminus_code: str - terminus_name: str - terminus_time: str - trip_id: str - def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ..., previous_trip_shape_id: _Optional[str] = ...) -> None: ... - ARRIVALS_FIELD_NUMBER: _ClassVar[int] - LOCATION_FIELD_NUMBER: _ClassVar[int] - STOP_ID_FIELD_NUMBER: _ClassVar[int] - arrivals: _containers.RepeatedCompositeFieldContainer[StopArrivals.ScheduledArrival] - location: Epsg25829 - stop_id: str - def __init__(self, stop_id: _Optional[str] = ..., location: _Optional[_Union[Epsg25829, _Mapping]] = ..., arrivals: _Optional[_Iterable[_Union[StopArrivals.ScheduledArrival, _Mapping]]] = ...) -> None: ... diff --git a/src/gtfs_vigo_stops/src/providers.py b/src/gtfs_vigo_stops/src/providers.py deleted file mode 100644 index f6414f6..0000000 --- a/src/gtfs_vigo_stops/src/providers.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Provider-specific configuration for different GTFS feed formats. -""" - -from typing import Protocol, Optional -from src.street_name import get_street_name - - -class FeedProvider(Protocol): - """Protocol defining provider-specific behavior for GTFS feeds.""" - - @staticmethod - def format_service_id(service_id: str) -> str: - """Format service_id for output.""" - ... - - @staticmethod - def format_trip_id(trip_id: str) -> str: - """Format trip_id for output.""" - ... - - @staticmethod - def format_route(route: str, terminus_name: str) -> str: - """Format route/headsign, potentially using terminus name as fallback.""" - ... - - @staticmethod - def extract_street_name(stop_name: str) -> str: - """Extract street name from stop name, or return full name.""" - ... - - -class VitrasaProvider: - """Provider configuration for Vitrasa (Vigo bus system).""" - - @staticmethod - def format_service_id(service_id: str) -> str: - """Extract middle part from underscore-separated service_id.""" - parts = service_id.split("_") - return parts[1] if len(parts) >= 2 else service_id - - @staticmethod - def format_trip_id(trip_id: str) -> str: - """Extract middle parts from underscore-separated trip_id.""" - parts = trip_id.split("_") - return "_".join(parts[1:3]) if len(parts) >= 3 else trip_id - - @staticmethod - def format_route(route: str, terminus_name: str) -> str: - """Return route as-is for Vitrasa.""" - return route - - @staticmethod - def extract_street_name(stop_name: str) -> str: - """Extract street name from stop name using standard logic.""" - return get_street_name(stop_name) or "" - - -class RenfeProvider: - """Provider configuration for Renfe (Spanish rail system).""" - - @staticmethod - def format_service_id(service_id: str) -> str: - """Use full service_id for Renfe (no underscores).""" - return service_id - - @staticmethod - def format_trip_id(trip_id: str) -> str: - """Use full trip_id for Renfe (no underscores).""" - return trip_id - - @staticmethod - def format_route(route: str, terminus_name: str) -> str: - """Use terminus name as route if route is empty.""" - return route if route else terminus_name - - @staticmethod - def extract_street_name(stop_name: str) -> str: - """Preserve full stop name for train stations.""" - return stop_name - - -class DefaultProvider: - """Default provider configuration for generic GTFS feeds.""" - - @staticmethod - def format_service_id(service_id: str) -> str: - """Try to extract from underscores, fallback to full ID.""" - parts = service_id.split("_") - return parts[1] if len(parts) >= 2 else service_id - - @staticmethod - def format_trip_id(trip_id: str) -> str: - """Try to extract from underscores, fallback to full ID.""" - parts = trip_id.split("_") - return "_".join(parts[1:3]) if len(parts) >= 3 else trip_id - - @staticmethod - def format_route(route: str, terminus_name: str) -> str: - """Use terminus name as route if route is empty.""" - return route if route else terminus_name - - @staticmethod - def extract_street_name(stop_name: str) -> str: - """Extract street name from stop name using standard logic.""" - return get_street_name(stop_name) or "" - - -# Provider registry -PROVIDERS = { - "vitrasa": VitrasaProvider, - "renfe": RenfeProvider, - "default": DefaultProvider, -} - - -def get_provider(provider_name: str) -> type[FeedProvider]: - """ - Get provider configuration by name. - - Args: - provider_name: Name of the provider (case-insensitive) - - Returns: - Provider class with configuration methods - - Raises: - ValueError: If provider name is not recognized - """ - provider_name_lower = provider_name.lower() - if provider_name_lower not in PROVIDERS: - raise ValueError( - f"Unknown provider: {provider_name}. " - f"Available providers: {', '.join(PROVIDERS.keys())}" - ) - return PROVIDERS[provider_name_lower] diff --git a/src/gtfs_vigo_stops/src/report_writer.py b/src/gtfs_vigo_stops/src/report_writer.py deleted file mode 100644 index f6d8763..0000000 --- a/src/gtfs_vigo_stops/src/report_writer.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Report writers for various output formats (HTML, JSON). -Centralizes all write operations for different report types. -""" - -import json -import os -from typing import Any, Dict, List - -from src.logger import get_logger -from src.proto.stop_schedule_pb2 import Epsg25829, StopArrivals - - -def write_stop_protobuf( - output_dir: str, - date: str, - stop_code: str, - arrivals: List[Dict[str, Any]], - stop_x: float, - stop_y: float, -) -> None: - """ - Write stop arrivals data to a Protobuf file. - - Args: - output_dir: Base output directory - date: Date string for the data - stop_code: Stop code identifier - stop_arrivals_proto: Serialized Protobuf data - """ - logger = get_logger("report_writer") - - item = StopArrivals( - stop_id=stop_code, - location=Epsg25829(x=stop_x, y=stop_y), - arrivals=[ - StopArrivals.ScheduledArrival( - service_id=arrival["service_id"], - trip_id=arrival["trip_id"], - line=arrival["line"], - route=arrival["route"], - shape_id=arrival["shape_id"], - shape_dist_traveled=arrival["shape_dist_traveled"], - stop_sequence=arrival["stop_sequence"], - next_streets=arrival["next_streets"], - starting_code=arrival["starting_code"], - starting_name=arrival["starting_name"], - starting_time=arrival["starting_time"], - calling_time=arrival["calling_time"], - calling_ssm=arrival["calling_ssm"], - terminus_code=arrival["terminus_code"], - terminus_name=arrival["terminus_name"], - terminus_time=arrival["terminus_time"], - previous_trip_shape_id=arrival.get("previous_trip_shape_id", ""), - ) - for arrival in arrivals - ], - ) - - try: - # Create the stops directory for this date - date_dir = os.path.join(output_dir, date) - os.makedirs(date_dir, exist_ok=True) - - # Create the Protobuf file - file_path = os.path.join(date_dir, f"{stop_code}.pb") - - with open(file_path, "wb") as f: - f.write(item.SerializeToString()) - - logger.debug(f"Stop Protobuf written to: {file_path}") - except Exception as e: - logger.error( - f"Error writing stop Protobuf to {output_dir}/stops/{date}/{stop_code}.pb: {e}" - ) - raise - - -def write_stop_json( - output_dir: str, date: str, stop_code: str, arrivals: List[Dict[str, Any]] -) -> None: - """ - Write stop arrivals data to a JSON file. - - Args: - output_dir: Base output directory - date: Date string for the data - stop_code: Stop code identifier - arrivals: List of arrival dictionaries - pretty: Whether to format JSON with indentation - """ - logger = get_logger("report_writer") - - try: - # Create the stops directory for this date - date_dir = os.path.join(output_dir, date) - os.makedirs(date_dir, exist_ok=True) - - # Create the JSON file - file_path = os.path.join(date_dir, f"{stop_code}.json") - - with open(file_path, "w", encoding="utf-8") as f: - json.dump(arrivals, f, ensure_ascii=False) - - logger.debug(f"Stop JSON written to: {file_path}") - except Exception as e: - logger.error( - f"Error writing stop JSON to {output_dir}/stops/{date}/{stop_code}.json: {e}" - ) - raise - - -def write_index_json( - output_dir: str, - data: Dict[str, Any], - filename: str = "index.json", - pretty: bool = False, -) -> None: - """ - Write index data to a JSON file. - - Args: - output_dir: Directory where the JSON file should be written - data: Dictionary containing the index data - filename: Name of the JSON file (default: "index.json") - pretty: Whether to format JSON with indentation - """ - logger = get_logger("report_writer") - - try: - # Create the output directory if it doesn't exist - os.makedirs(output_dir, exist_ok=True) - - # Write the index.json file - index_filepath = os.path.join(output_dir, filename) - with open(index_filepath, "w", encoding="utf-8") as f: - if pretty: - json.dump(data, f, ensure_ascii=False, indent=2) - else: - json.dump(data, f, ensure_ascii=False, separators=(",", ":")) - - logger.info(f"Index JSON written to: {index_filepath}") - except Exception as e: - logger.error(f"Error writing index JSON to {output_dir}/{filename}: {e}") - raise diff --git a/src/gtfs_vigo_stops/src/routes.py b/src/gtfs_vigo_stops/src/routes.py deleted file mode 100644 index e67a1a4..0000000 --- a/src/gtfs_vigo_stops/src/routes.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Module for loading and querying GTFS routes data. -""" -import os -import csv -from src.logger import get_logger - -logger = get_logger("routes") - -def load_routes(feed_dir: str) -> dict[str, dict[str, str]]: - """ - Load routes data from the GTFS feed. - - Returns: - dict[str, dict[str, str]]: A dictionary where keys are route IDs and values are dictionaries - containing route_short_name and route_color. - """ - routes: dict[str, dict[str, str]] = {} - routes_file_path = os.path.join(feed_dir, 'routes.txt') - - try: - with open(routes_file_path, 'r', encoding='utf-8') as routes_file: - reader = csv.DictReader(routes_file) - header = reader.fieldnames or [] - if 'route_color' not in header: - logger.warning("Column 'route_color' not found in routes.txt. Defaulting to black (#000000).") - - for row in reader: - route_id = row['route_id'] - if 'route_color' in row and row['route_color']: - route_color = row['route_color'] - else: - route_color = '000000' - routes[route_id] = { - 'route_short_name': row['route_short_name'], - 'route_color': route_color - } - except FileNotFoundError: - raise FileNotFoundError(f"Routes file not found at {routes_file_path}") - except KeyError as e: - raise KeyError(f"Missing required column in routes file: {e}") - - return routes diff --git a/src/gtfs_vigo_stops/src/services.py b/src/gtfs_vigo_stops/src/services.py deleted file mode 100644 index fb1110d..0000000 --- a/src/gtfs_vigo_stops/src/services.py +++ /dev/null @@ -1,113 +0,0 @@ -import os -import datetime -from src.logger import get_logger - -logger = get_logger("services") - - -def get_active_services(feed_dir: str, date: str) -> list[str]: - """ - Get active services for a given date based on the 'calendar.txt' and 'calendar_dates.txt' files. - - Args: - date (str): Date in 'YYYY-MM-DD' format. - - Returns: - list[str]: List of active service IDs for the given date. - - Raises: - ValueError: If the date format is incorrect. - """ - search_date = date.replace("-", "").replace(":", "").replace("/", "") - weekday = datetime.datetime.strptime(date, '%Y-%m-%d').weekday() - active_services: list[str] = [] - - try: - with open(os.path.join(feed_dir, 'calendar.txt'), 'r', encoding="utf-8") as calendar_file: - lines = calendar_file.readlines() - if len(lines) > 1: - # First parse the header, get each column's index - header = lines[0].strip().split(',') - try: - service_id_index = header.index('service_id') - monday_index = header.index('monday') - tuesday_index = header.index('tuesday') - wednesday_index = header.index('wednesday') - thursday_index = header.index('thursday') - friday_index = header.index('friday') - saturday_index = header.index('saturday') - sunday_index = header.index('sunday') - start_date_index = header.index('start_date') - end_date_index = header.index('end_date') - except ValueError as e: - logger.error(f"Required column not found in header: {e}") - return active_services - # Now read the rest of the file, find all services where the day of the week matches - weekday_columns = { - 0: monday_index, - 1: tuesday_index, - 2: wednesday_index, - 3: thursday_index, - 4: friday_index, - 5: saturday_index, - 6: sunday_index - } - - for idx, line in enumerate(lines[1:], 1): - parts = line.strip().split(',') - if len(parts) < len(header): - logger.warning( - f"Skipping malformed line in calendar.txt line {idx+1}: {line.strip()}") - continue - - service_id = parts[service_id_index] - day_value = parts[weekday_columns[weekday]] - start_date = parts[start_date_index] - end_date = parts[end_date_index] - - # Check if day of week is active AND date is within the service range - if day_value == '1' and start_date <= search_date <= end_date: - active_services.append(service_id) - except FileNotFoundError: - logger.warning("calendar.txt file not found.") - - try: - with open(os.path.join(feed_dir, 'calendar_dates.txt'), 'r', encoding="utf-8") as calendar_dates_file: - lines = calendar_dates_file.readlines() - if len(lines) <= 1: - logger.warning( - "calendar_dates.txt file is empty or has only header line, not processing.") - return active_services - - header = lines[0].strip().split(',') - try: - service_id_index = header.index('service_id') - date_index = header.index('date') - exception_type_index = header.index('exception_type') - except ValueError as e: - logger.error(f"Required column not found in header: {e}") - return active_services - - # Now read the rest of the file, find all services where 'date' matches the search_date - # Start from 1 to skip header - for idx, line in enumerate(lines[1:], 1): - parts = line.strip().split(',') - if len(parts) < len(header): - logger.warning( - f"Skipping malformed line in calendar_dates.txt line {idx+1}: {line.strip()}") - continue - - service_id = parts[service_id_index] - date_value = parts[date_index] - exception_type = parts[exception_type_index] - - if date_value == search_date and exception_type == '1': - active_services.append(service_id) - - if date_value == search_date and exception_type == '2': - if service_id in active_services: - active_services.remove(service_id) - except FileNotFoundError: - logger.warning("calendar_dates.txt file not found.") - - return active_services diff --git a/src/gtfs_vigo_stops/src/shapes.py b/src/gtfs_vigo_stops/src/shapes.py deleted file mode 100644 index f49832a..0000000 --- a/src/gtfs_vigo_stops/src/shapes.py +++ /dev/null @@ -1,88 +0,0 @@ -import csv -from dataclasses import dataclass -import os -from typing import Dict, Optional - -from pyproj import Transformer - -from src.logger import get_logger - - -logger = get_logger("shapes") - - -@dataclass -class Shape: - shape_id: str - shape_pt_lat: Optional[float] - shape_pt_lon: Optional[float] - shape_pt_position: Optional[int] - shape_dist_traveled: Optional[float] - - shape_pt_25829_x: Optional[float] = None - shape_pt_25829_y: Optional[float] = None - - -def process_shapes(feed_dir: str, out_dir: str) -> None: - file_path = os.path.join(feed_dir, "shapes.txt") - shapes: Dict[str, list[Shape]] = {} - - transformer = Transformer.from_crs(4326, 25829, always_xy=True) - - try: - with open(file_path, "r", encoding="utf-8", newline="") as f: - reader = csv.DictReader(f, quotechar='"', delimiter=",") - for row_num, row in enumerate(reader, start=2): - try: - shape = Shape( - shape_id=row["shape_id"], - shape_pt_lat=float(row["shape_pt_lat"]) if row.get("shape_pt_lat") else None, - shape_pt_lon=float(row["shape_pt_lon"]) if row.get("shape_pt_lon") else None, - shape_pt_position=int(row["shape_pt_position"]) if row.get("shape_pt_position") else None, - shape_dist_traveled=float(row["shape_dist_traveled"]) if row.get("shape_dist_traveled") else None, - ) - - if shape.shape_pt_lat is not None and shape.shape_pt_lon is not None: - shape_pt_25829_x, shape_pt_25829_y = transformer.transform( - shape.shape_pt_lon, shape.shape_pt_lat - ) - shape.shape_pt_25829_x = shape_pt_25829_x - shape.shape_pt_25829_y = shape_pt_25829_y - - if shape.shape_id not in shapes: - shapes[shape.shape_id] = [] - shapes[shape.shape_id].append(shape) - except Exception as e: - logger.warning( - f"Error parsing stops.txt line {row_num}: {e} - line data: {row}" - ) - except FileNotFoundError: - logger.error(f"File not found: {file_path}") - except Exception as e: - logger.error(f"Error reading stops.txt: {e}") - - - # Write shapes to Protobuf files - from src.proto.stop_schedule_pb2 import Epsg25829, Shape as PbShape - - for shape_id, shape_points in shapes.items(): - points = sorted(shape_points, key=lambda sp: sp.shape_pt_position if sp.shape_pt_position is not None else 0) - - pb_shape = PbShape( - shape_id=shape_id, - points=[ - Epsg25829(x=pt.shape_pt_25829_x, y=pt.shape_pt_25829_y) - for pt in points - if pt.shape_pt_25829_x is not None and pt.shape_pt_25829_y is not None - ], - ) - - shape_file_path = os.path.join(out_dir, "shapes", f"{shape_id}.pb") - os.makedirs(os.path.dirname(shape_file_path), exist_ok=True) - - try: - with open(shape_file_path, "wb") as f: - f.write(pb_shape.SerializeToString()) - logger.debug(f"Shape Protobuf written to: {shape_file_path}") - except Exception as e: - logger.error(f"Error writing shape Protobuf to {shape_file_path}: {e}") diff --git a/src/gtfs_vigo_stops/src/stop_schedule_pb2.py b/src/gtfs_vigo_stops/src/stop_schedule_pb2.py deleted file mode 100644 index 285b057..0000000 --- a/src/gtfs_vigo_stops/src/stop_schedule_pb2.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: stop_schedule.proto -# Protobuf Python Version: 6.33.0 -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 33, - 0, - '', - 'stop_schedule.proto' -) -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\xe3\x03\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\xe5\x02\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\tB$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' - _globals['_EPSG25829']._serialized_start=30 - _globals['_EPSG25829']._serialized_end=63 - _globals['_STOPARRIVALS']._serialized_start=66 - _globals['_STOPARRIVALS']._serialized_end=549 - _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_start=192 - _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_end=549 -# @@protoc_insertion_point(module_scope) diff --git a/src/gtfs_vigo_stops/src/stop_schedule_pb2.pyi b/src/gtfs_vigo_stops/src/stop_schedule_pb2.pyi deleted file mode 100644 index aa42cdb..0000000 --- a/src/gtfs_vigo_stops/src/stop_schedule_pb2.pyi +++ /dev/null @@ -1,60 +0,0 @@ -from google.protobuf.internal import containers as _containers -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from collections.abc import Iterable as _Iterable, Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class Epsg25829(_message.Message): - __slots__ = () - X_FIELD_NUMBER: _ClassVar[int] - Y_FIELD_NUMBER: _ClassVar[int] - x: float - y: float - def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> None: ... - -class StopArrivals(_message.Message): - __slots__ = () - class ScheduledArrival(_message.Message): - __slots__ = () - SERVICE_ID_FIELD_NUMBER: _ClassVar[int] - TRIP_ID_FIELD_NUMBER: _ClassVar[int] - LINE_FIELD_NUMBER: _ClassVar[int] - ROUTE_FIELD_NUMBER: _ClassVar[int] - SHAPE_ID_FIELD_NUMBER: _ClassVar[int] - SHAPE_DIST_TRAVELED_FIELD_NUMBER: _ClassVar[int] - STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] - NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] - STARTING_CODE_FIELD_NUMBER: _ClassVar[int] - STARTING_NAME_FIELD_NUMBER: _ClassVar[int] - STARTING_TIME_FIELD_NUMBER: _ClassVar[int] - CALLING_TIME_FIELD_NUMBER: _ClassVar[int] - CALLING_SSM_FIELD_NUMBER: _ClassVar[int] - TERMINUS_CODE_FIELD_NUMBER: _ClassVar[int] - TERMINUS_NAME_FIELD_NUMBER: _ClassVar[int] - TERMINUS_TIME_FIELD_NUMBER: _ClassVar[int] - service_id: str - trip_id: str - line: str - route: str - shape_id: str - shape_dist_traveled: float - stop_sequence: int - next_streets: _containers.RepeatedScalarFieldContainer[str] - starting_code: str - starting_name: str - starting_time: str - calling_time: str - calling_ssm: int - terminus_code: str - terminus_name: str - terminus_time: str - def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ...) -> None: ... - STOP_ID_FIELD_NUMBER: _ClassVar[int] - LOCATION_FIELD_NUMBER: _ClassVar[int] - ARRIVALS_FIELD_NUMBER: _ClassVar[int] - stop_id: str - location: Epsg25829 - arrivals: _containers.RepeatedCompositeFieldContainer[StopArrivals.ScheduledArrival] - def __init__(self, stop_id: _Optional[str] = ..., location: _Optional[_Union[Epsg25829, _Mapping]] = ..., arrivals: _Optional[_Iterable[_Union[StopArrivals.ScheduledArrival, _Mapping]]] = ...) -> None: ... diff --git a/src/gtfs_vigo_stops/src/stop_times.py b/src/gtfs_vigo_stops/src/stop_times.py deleted file mode 100644 index f3c3f25..0000000 --- a/src/gtfs_vigo_stops/src/stop_times.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Functions for handling GTFS stop_times data. -""" -import csv -import os -from src.logger import get_logger - -logger = get_logger("stop_times") - - -STOP_TIMES_BY_FEED: dict[str, dict[str, list["StopTime"]]] = {} -STOP_TIMES_BY_REQUEST: dict[tuple[str, frozenset[str]], dict[str, list["StopTime"]]] = {} - -class StopTime: - """ - Class representing a stop time entry in the GTFS data. - """ - def __init__(self, trip_id: str, arrival_time: str, departure_time: str, stop_id: str, stop_sequence: int, shape_dist_traveled: float | None): - self.trip_id = trip_id - self.arrival_time = arrival_time - self.departure_time = departure_time - self.stop_id = stop_id - self.stop_sequence = stop_sequence - self.shape_dist_traveled = shape_dist_traveled - self.day_change = False # New attribute to indicate day change - - def __str__(self): - return f"StopTime({self.trip_id=}, {self.arrival_time=}, {self.departure_time=}, {self.stop_id=}, {self.stop_sequence=})" - - -def _load_stop_times_for_feed(feed_dir: str) -> dict[str, list[StopTime]]: - """Load and cache all stop_times for a feed directory.""" - if feed_dir in STOP_TIMES_BY_FEED: - return STOP_TIMES_BY_FEED[feed_dir] - - stops: dict[str, list[StopTime]] = {} - - try: - with open(os.path.join(feed_dir, 'stop_times.txt'), 'r', encoding="utf-8", newline='') as stop_times_file: - reader = csv.DictReader(stop_times_file) - if reader.fieldnames is None: - logger.error("stop_times.txt missing header row.") - STOP_TIMES_BY_FEED[feed_dir] = {} - return STOP_TIMES_BY_FEED[feed_dir] - - required_columns = ['trip_id', 'arrival_time', 'departure_time', 'stop_id', 'stop_sequence'] - missing_columns = [col for col in required_columns if col not in reader.fieldnames] - if missing_columns: - logger.error(f"Required columns not found in header: {missing_columns}") - STOP_TIMES_BY_FEED[feed_dir] = {} - return STOP_TIMES_BY_FEED[feed_dir] - - has_shape_dist = 'shape_dist_traveled' in reader.fieldnames - if not has_shape_dist: - logger.warning("Column 'shape_dist_traveled' not found in stop_times.txt. Distances will be set to None.") - - for row in reader: - trip_id = row['trip_id'] - if trip_id not in stops: - stops[trip_id] = [] - - dist = None - if has_shape_dist and row['shape_dist_traveled']: - try: - dist = float(row['shape_dist_traveled']) - except ValueError: - pass - - try: - stops[trip_id].append(StopTime( - trip_id=trip_id, - arrival_time=row['arrival_time'], - departure_time=row['departure_time'], - stop_id=row['stop_id'], - stop_sequence=int(row['stop_sequence']), - shape_dist_traveled=dist - )) - except ValueError as e: - logger.warning(f"Error parsing stop_sequence for trip {trip_id}: {e}") - - for trip_stop_times in stops.values(): - trip_stop_times.sort(key=lambda st: st.stop_sequence) - - except FileNotFoundError: - logger.warning("stop_times.txt file not found.") - stops = {} - - STOP_TIMES_BY_FEED[feed_dir] = stops - return stops - - -def get_stops_for_trips(feed_dir: str, trip_ids: list[str]) -> dict[str, list[StopTime]]: - """ - Get stops for a list of trip IDs based on the cached 'stop_times.txt' data. - """ - if not trip_ids: - return {} - - request_key = (feed_dir, frozenset(trip_ids)) - cached_subset = STOP_TIMES_BY_REQUEST.get(request_key) - if cached_subset is not None: - return cached_subset - - feed_cache = _load_stop_times_for_feed(feed_dir) - if not feed_cache: - STOP_TIMES_BY_REQUEST[request_key] = {} - return {} - - result: dict[str, list[StopTime]] = {} - seen: set[str] = set() - for trip_id in trip_ids: - if trip_id in seen: - continue - seen.add(trip_id) - trip_stop_times = feed_cache.get(trip_id) - if trip_stop_times: - result[trip_id] = trip_stop_times - - STOP_TIMES_BY_REQUEST[request_key] = result - return result diff --git a/src/gtfs_vigo_stops/src/stops.py b/src/gtfs_vigo_stops/src/stops.py deleted file mode 100644 index bb54fa4..0000000 --- a/src/gtfs_vigo_stops/src/stops.py +++ /dev/null @@ -1,100 +0,0 @@ -import csv -import os -from dataclasses import dataclass -from typing import Dict, Optional - -from pyproj import Transformer - -from src.logger import get_logger - -logger = get_logger("stops") - - -@dataclass -class Stop: - stop_id: str - stop_code: Optional[str] - stop_name: Optional[str] - stop_lat: Optional[float] - stop_lon: Optional[float] - - stop_25829_x: Optional[float] = None - stop_25829_y: Optional[float] = None - - -CACHED_STOPS: dict[str, dict[str, Stop]] = {} -CACHED_BY_CODE: dict[str, dict[str, Stop]] = {} - - -def get_all_stops_by_code(feed_dir: str) -> Dict[str, Stop]: - if feed_dir in CACHED_BY_CODE: - return CACHED_BY_CODE[feed_dir] - - transformer = Transformer.from_crs(4326, 25829, always_xy=True) - - stops_by_code: Dict[str, Stop] = {} - all_stops = get_all_stops(feed_dir) - - for stop in all_stops.values(): - stop_25829_x, stop_25829_y = transformer.transform( - stop.stop_lon, stop.stop_lat - ) - stop.stop_25829_x = stop_25829_x - stop.stop_25829_y = stop_25829_y - - if stop.stop_code: - stops_by_code[get_numeric_code(stop.stop_code)] = stop - else: - stops_by_code[stop.stop_id] = stop - - CACHED_BY_CODE[feed_dir] = stops_by_code - - return stops_by_code - - -def get_all_stops(feed_dir: str) -> Dict[str, Stop]: - if feed_dir in CACHED_STOPS: - return CACHED_STOPS[feed_dir] - - stops: Dict[str, Stop] = {} - file_path = os.path.join(feed_dir, "stops.txt") - - try: - with open(file_path, "r", encoding="utf-8", newline="") as f: - reader = csv.DictReader(f, quotechar='"', delimiter=",") - for row_num, row in enumerate(reader, start=2): - try: - stop = Stop( - stop_id=row["stop_id"], - stop_code=row.get("stop_code"), - stop_name=row["stop_name"].strip() - if row.get("stop_name", "").strip() - else row.get("stop_desc"), - stop_lat=float(row["stop_lat"]) - if row.get("stop_lat") - else None, - stop_lon=float(row["stop_lon"]) - if row.get("stop_lon") - else None, - ) - stops[stop.stop_id] = stop - except Exception as e: - logger.warning( - f"Error parsing stops.txt line {row_num}: {e} - line data: {row}" - ) - - except FileNotFoundError: - logger.error(f"File not found: {file_path}") - except Exception as e: - logger.error(f"Error reading stops.txt: {e}") - - CACHED_STOPS[feed_dir] = stops - - return stops - - -def get_numeric_code(stop_code: str | None) -> str: - if not stop_code: - return "" - numeric_code = "".join(c for c in stop_code if c.isdigit()) - return str(int(numeric_code)) if numeric_code else "" diff --git a/src/gtfs_vigo_stops/src/street_name.py b/src/gtfs_vigo_stops/src/street_name.py deleted file mode 100644 index ec6b5b6..0000000 --- a/src/gtfs_vigo_stops/src/street_name.py +++ /dev/null @@ -1,49 +0,0 @@ -import re - - -re_remove_quotation_marks = re.compile(r'[""”]', re.IGNORECASE) -re_anything_before_stopcharacters_with_parentheses = re.compile( - r'^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()', re.IGNORECASE) - - -NAME_REPLACEMENTS = { - "Rúa da Salguera Entrada": "Rúa da Salgueira", - "Rúa da Salgueira Entrada": "Rúa da Salgueira", - "Estrada de Miraflores": "Estrada Miraflores", - "FORA DE SERVIZO.G.B.": "", - "Praza de Fernando O Católico": "", - "Rúa da Travesía de Vigo": "Travesía de Vigo", - " de ": " ", - " do ": " ", - " da ": " ", - " das ": " ", - "Riós": "Ríos" -} - - -def get_street_name(original_name: str) -> str: - original_name = re.sub(re_remove_quotation_marks, - '', original_name).strip() - match = re.match( - re_anything_before_stopcharacters_with_parentheses, original_name) - if match: - street_name = match.group(1) - else: - street_name = original_name - - for old_name, new_name in NAME_REPLACEMENTS.items(): - if old_name.lower() in street_name.lower(): - street_name = street_name.replace(old_name, new_name) - return street_name.strip() - - return street_name - - -def normalise_stop_name(original_name: str | None) -> str: - if original_name is None: - return '' - stop_name = re.sub(re_remove_quotation_marks, '', original_name).strip() - - stop_name = stop_name.replace(' ', ', ') - - return stop_name diff --git a/src/gtfs_vigo_stops/src/trips.py b/src/gtfs_vigo_stops/src/trips.py deleted file mode 100644 index 0cedd26..0000000 --- a/src/gtfs_vigo_stops/src/trips.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Functions for handling GTFS trip data. -""" -import os -from src.logger import get_logger - -logger = get_logger("trips") - -class TripLine: - """ - Class representing a trip line in the GTFS data. - """ - def __init__(self, route_id: str, service_id: str, trip_id: str, headsign: str, direction_id: int, shape_id: str|None = None, block_id: str|None = None): - self.route_id = route_id - self.service_id = service_id - self.trip_id = trip_id - self.headsign = headsign - self.direction_id = direction_id - self.shape_id = shape_id - self.block_id = block_id - self.route_short_name = "" - self.route_color = "" - - def __str__(self): - return f"TripLine({self.route_id=}, {self.service_id=}, {self.trip_id=}, {self.headsign=}, {self.direction_id=}, {self.shape_id=}, {self.block_id=})" - - -TRIPS_BY_SERVICE_ID: dict[str, dict[str, list[TripLine]]] = {} - - -def get_trips_for_services(feed_dir: str, service_ids: list[str]) -> dict[str, list[TripLine]]: - """ - Get trips for a list of service IDs based on the 'trips.txt' file. - Uses caching to avoid reading and parsing the file multiple times. - - Args: - feed_dir (str): Directory containing the GTFS feed files. - service_ids (list[str]): List of service IDs to find trips for. - - Returns: - dict[str, list[TripLine]]: Dictionary mapping service IDs to lists of trip objects. - """ - # Check if we already have cached data for this feed directory - if feed_dir in TRIPS_BY_SERVICE_ID: - logger.debug(f"Using cached trips data for {feed_dir}") - # Return only the trips for the requested service IDs - return {service_id: TRIPS_BY_SERVICE_ID[feed_dir].get(service_id, []) - for service_id in service_ids} - - trips: dict[str, list[TripLine]] = {} - - try: - with open(os.path.join(feed_dir, 'trips.txt'), 'r', encoding="utf-8") as trips_file: - lines = trips_file.readlines() - if len(lines) <= 1: - logger.warning( - "trips.txt file is empty or has only header line, not processing.") - return trips - - header = lines[0].strip().split(',') - try: - service_id_index = header.index('service_id') - trip_id_index = header.index('trip_id') - route_id_index = header.index('route_id') - headsign_index = header.index('trip_headsign') - direction_id_index = header.index('direction_id') - except ValueError as e: - logger.error(f"Required column not found in header: {e}") - return trips - - # Check if shape_id column exists - shape_id_index = None - if 'shape_id' in header: - shape_id_index = header.index('shape_id') - else: - logger.warning("shape_id column not found in trips.txt") - - # Check if block_id column exists - block_id_index = None - if 'block_id' in header: - block_id_index = header.index('block_id') - else: - logger.info("block_id column not found in trips.txt") - - # Initialize cache for this feed directory - TRIPS_BY_SERVICE_ID[feed_dir] = {} - - for line in lines[1:]: - parts = line.strip().split(',') - if len(parts) < len(header): - logger.warning( - f"Skipping malformed line in trips.txt: {line.strip()}") - continue - - service_id = parts[service_id_index] - trip_id = parts[trip_id_index] - - # Cache all trips, not just the ones requested - if service_id not in TRIPS_BY_SERVICE_ID[feed_dir]: - TRIPS_BY_SERVICE_ID[feed_dir][service_id] = [] - - # Get shape_id if available - shape_id = None - if shape_id_index is not None and shape_id_index < len(parts): - shape_id = parts[shape_id_index] if parts[shape_id_index] else None - - # Get block_id if available - block_id = None - if block_id_index is not None and block_id_index < len(parts): - block_id = parts[block_id_index] if parts[block_id_index] else None - - trip_line = TripLine( - route_id=parts[route_id_index], - service_id=service_id, - trip_id=trip_id, - headsign=parts[headsign_index], - direction_id=int( - parts[direction_id_index] if parts[direction_id_index] else -1), - shape_id=shape_id, - block_id=block_id - ) - - TRIPS_BY_SERVICE_ID[feed_dir][service_id].append(trip_line) - - # Also build the result for the requested service IDs - if service_id in service_ids: - if service_id not in trips: - trips[service_id] = [] - trips[service_id].append(trip_line) - - except FileNotFoundError: - logger.warning("trips.txt file not found.") - - return trips diff --git a/src/gtfs_vigo_stops/stop_report.py b/src/gtfs_vigo_stops/stop_report.py deleted file mode 100644 index f8fdc64..0000000 --- a/src/gtfs_vigo_stops/stop_report.py +++ /dev/null @@ -1,666 +0,0 @@ -import argparse -import os -import shutil -import sys -import time -import traceback -from typing import Any, Dict, List, Optional, Tuple - -from src.shapes import process_shapes -from src.common import get_all_feed_dates -from src.download import download_feed_from_url -from src.logger import get_logger -from src.report_writer import write_stop_json, write_stop_protobuf -from src.routes import load_routes -from src.services import get_active_services -from src.stop_times import get_stops_for_trips, StopTime -from src.stops import get_all_stops, get_all_stops_by_code, get_numeric_code -from src.street_name import normalise_stop_name -from src.trips import get_trips_for_services, TripLine -from src.providers import get_provider - -logger = get_logger("stop_report") - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Generate stop-based JSON reports for a date or date range." - ) - parser.add_argument( - "--output-dir", - type=str, - default="./output/", - help="Directory to write reports to (default: ./output/)", - ) - parser.add_argument("--feed-dir", type=str, - help="Path to the feed directory") - parser.add_argument( - "--feed-url", - type=str, - help="URL to download the GTFS feed from (if not using local feed directory)", - ) - parser.add_argument( - "--force-download", - action="store_true", - help="Force download even if the feed hasn't been modified (only applies when using --feed-url)", - ) - parser.add_argument( - "--provider", - type=str, - default="default", - help="Feed provider type (vitrasa, renfe, default). Default: default", - ) - args = parser.parse_args() - - if args.feed_dir and args.feed_url: - parser.error("Specify either --feed-dir or --feed-url, not both.") - if not args.feed_dir and not args.feed_url: - parser.error( - "You must specify either a path to the existing feed (unzipped) or a URL to download the GTFS feed from." - ) - if args.feed_dir and not os.path.exists(args.feed_dir): - parser.error(f"Feed directory does not exist: {args.feed_dir}") - return args - - -def time_to_seconds(time_str: str) -> int: - """ - Convert HH:MM:SS to seconds since midnight. - Handles GTFS times that can exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day). - """ - if not time_str: - return 0 - - parts = time_str.split(":") - if len(parts) != 3: - return 0 - - try: - hours, minutes, seconds = map(int, parts) - return hours * 3600 + minutes * 60 + seconds - except ValueError: - return 0 - - -def normalize_gtfs_time(time_str: str) -> str: - """ - Normalize GTFS time format to standard HH:MM:SS (0-23 hours). - Converts times like 25:30:00 to 01:30:00. - - Args: - time_str: Time in HH:MM:SS format, possibly with hours >= 24 - - Returns: - Normalized time string in HH:MM:SS format - """ - if not time_str: - return time_str - - parts = time_str.split(":") - if len(parts) != 3: - return time_str - - try: - hours, minutes, seconds = map(int, parts) - normalized_hours = hours % 24 - return f"{normalized_hours:02d}:{minutes:02d}:{seconds:02d}" - except ValueError: - return time_str - - -def format_gtfs_time(time_str: str) -> str: - """ - Format GTFS time to HH:MM:SS, preserving hours >= 24. - """ - if not time_str: - return time_str - - parts = time_str.split(":") - if len(parts) != 3: - return time_str - - try: - hours, minutes, seconds = map(int, parts) - return f"{hours:02d}:{minutes:02d}:{seconds:02d}" - except ValueError: - return time_str - - -def is_next_day_service(time_str: str) -> bool: - """ - Check if a GTFS time represents a service on the next day (hours >= 24). - - Args: - time_str: Time in HH:MM:SS format - - Returns: - True if the time is >= 24:00:00, False otherwise - """ - if not time_str: - return False - - parts = time_str.split(":") - if len(parts) != 3: - return False - - try: - hours = int(parts[0]) - return hours >= 24 - except ValueError: - return False - - -def parse_trip_id_components(trip_id: str) -> Optional[Tuple[str, str, int]]: - """ - Parse a trip ID in format XXXYYY-Z or XXXYYY_Z where: - - XXX = line number (e.g., 003) - - YYY = shift/internal ID (e.g., 001) - - Z = trip number (e.g., 12) - - Supported formats: - 1. ..._XXXYYY_Z (e.g. "C1 01SNA00_001001_18") - 2. ..._XXXYYY-Z (e.g. "VIGO_20241122_003001-12") - - Returns tuple of (line, shift_id, trip_number) or None if parsing fails. - """ - try: - parts = trip_id.split("_") - if len(parts) < 2: - return None - - # Try format 1: ..._XXXYYY_Z - # Check if second to last part is 6 digits (XXXYYY) and last part is numeric - if len(parts) >= 2: - shift_part = parts[-2] - trip_num_str = parts[-1] - if len(shift_part) == 6 and shift_part.isdigit() and trip_num_str.isdigit(): - line = shift_part[:3] - shift_id = shift_part[3:6] - trip_number = int(trip_num_str) - return (line, shift_id, trip_number) - - # Try format 2: ..._XXXYYY-Z - # The trip ID is the last part in format XXXYYY-Z - trip_part = parts[-1] - - if "-" in trip_part: - shift_part, trip_num_str = trip_part.split("-", 1) - - # shift_part should be 6 digits: XXXYYY - if len(shift_part) == 6 and shift_part.isdigit(): - line = shift_part[:3] # First 3 digits - shift_id = shift_part[3:6] # Next 3 digits - trip_number = int(trip_num_str) - return (line, shift_id, trip_number) - - return None - except (ValueError, IndexError): - return None - - -def build_trip_previous_shape_map( - trips: Dict[str, List[TripLine]], - stops_for_all_trips: Dict[str, List[StopTime]], -) -> Dict[str, Optional[str]]: - """ - Build a mapping from trip_id to previous_trip_shape_id. - - Links trips based on trip ID structure (XXXYYY-Z) where trips with the same - XXX (line) and YYY (shift) and sequential Z (trip numbers) are connected - if the terminus of trip N matches the start of trip N+1. - - Args: - trips: Dictionary of service_id -> list of trips - stops_for_all_trips: Dictionary of trip_id -> list of stop times - - Returns: - Dictionary mapping trip_id to previous_trip_shape_id (or None) - """ - trip_previous_shape: Dict[str, Optional[str]] = {} - - # Collect all trips across all services - all_trips_list: List[TripLine] = [] - for trip_list in trips.values(): - all_trips_list.extend(trip_list) - - # Group trips by shift ID (line + shift combination) - trips_by_shift: Dict[str, List[Tuple[TripLine, int, str, str]]] = {} - - for trip in all_trips_list: - parsed = parse_trip_id_components(trip.trip_id) - if not parsed: - continue - - line, shift_id, trip_number = parsed - shift_key = f"{line}{shift_id}" - - trip_stops = stops_for_all_trips.get(trip.trip_id) - if not trip_stops or len(trip_stops) < 2: - continue - - first_stop = trip_stops[0] - last_stop = trip_stops[-1] - - if shift_key not in trips_by_shift: - trips_by_shift[shift_key] = [] - - trips_by_shift[shift_key].append(( - trip, - trip_number, - first_stop.stop_id, - last_stop.stop_id - )) - # For each shift, sort trips by trip number and link consecutive trips - for shift_key, shift_trips in trips_by_shift.items(): - # Sort by trip number - shift_trips.sort(key=lambda x: x[1]) - # Link consecutive trips if their stops match - for i in range(1, len(shift_trips)): - current_trip, current_num, current_start_stop, _ = shift_trips[i] - prev_trip, prev_num, _, prev_end_stop = shift_trips[i - 1] - - # Check if trips are consecutive (trip numbers differ by 1), - # if previous trip's terminus matches current trip's start, - # and if both trips have valid shape IDs - if (current_num == prev_num + 1 and - prev_end_stop == current_start_stop and - prev_trip.shape_id and - current_trip.shape_id): - trip_previous_shape[current_trip.trip_id] = prev_trip.shape_id - - return trip_previous_shape - - -def get_stop_arrivals(feed_dir: str, date: str, provider) -> Dict[str, List[Dict[str, Any]]]: - """ - Process trips for the given date and organize stop arrivals. - Also includes night services from the previous day (times >= 24:00:00). - - Args: - feed_dir: Path to the GTFS feed directory - date: Date in YYYY-MM-DD format - provider: Provider class with feed-specific formatting methods - - Returns: - Dictionary mapping stop_code to lists of arrival information. - """ - from datetime import datetime, timedelta - - stops = get_all_stops(feed_dir) - logger.info(f"Found {len(stops)} stops in the feed.") - - active_services = get_active_services(feed_dir, date) - if not active_services: - logger.info("No active services found for the given date.") - - logger.info( - f"Found {len(active_services)} active services for date {date}.") - - # Also get services from the previous day to include night services (times >= 24:00) - prev_date = (datetime.strptime(date, "%Y-%m-%d") - - timedelta(days=1)).strftime("%Y-%m-%d") - prev_services = get_active_services(feed_dir, prev_date) - logger.info( - f"Found {len(prev_services)} active services for previous date {prev_date} (for night services).") - - all_services = list(set(active_services + prev_services)) - - if not all_services: - logger.info("No active services found for current or previous date.") - return {} - - trips = get_trips_for_services(feed_dir, all_services) - total_trip_count = sum(len(trip_list) for trip_list in trips.values()) - logger.info(f"Found {total_trip_count} trips for active services.") - - # Get all trip IDs - all_trip_ids = [trip.trip_id for trip_list in trips.values() - for trip in trip_list] - - # Get stops for all trips - stops_for_all_trips = get_stops_for_trips(feed_dir, all_trip_ids) - logger.info(f"Precomputed stops for {len(stops_for_all_trips)} trips.") - - # Build mapping from trip_id to previous trip's shape_id - trip_previous_shape_map = build_trip_previous_shape_map( - trips, stops_for_all_trips) - logger.info( - f"Built previous trip shape mapping for {len(trip_previous_shape_map)} trips.") - - # Load routes information - routes = load_routes(feed_dir) - logger.info(f"Loaded {len(routes)} routes from feed.") - - # Create a reverse lookup from stop_id to stop_code (or stop_id as fallback) - stop_id_to_code = {} - for stop_id, stop in stops.items(): - if stop.stop_code: - stop_id_to_code[stop_id] = get_numeric_code(stop.stop_code) - else: - # Fallback to stop_id if stop_code is not available (e.g., train stations) - stop_id_to_code[stop_id] = stop_id - - # Organize data by stop_code - stop_arrivals = {} - - active_services_set = set(active_services) - prev_services_set = set(prev_services) - - for service_id, trip_list in trips.items(): - is_active = service_id in active_services_set - is_prev = service_id in prev_services_set - - if not is_active and not is_prev: - continue - - for trip in trip_list: - # Get route information once per trip - route_info = routes.get(trip.route_id, {}) - route_short_name = route_info.get("route_short_name", "") - trip_headsign = getattr(trip, "headsign", "") or "" - trip_id = trip.trip_id - - # Get stop times for this trip - trip_stops = stops_for_all_trips.get(trip.trip_id, []) - if not trip_stops: - continue - - # Pair stop_times with stop metadata once to avoid repeated lookups - trip_stop_pairs = [] - stop_names = [] - for stop_time in trip_stops: - stop = stops.get(stop_time.stop_id) - trip_stop_pairs.append((stop_time, stop)) - stop_names.append(stop.stop_name if stop else "Unknown Stop") - - # Memoize street names per stop name for this trip and build segments - street_cache: dict[str, str] = {} - segment_names: list[str] = [] - stop_to_segment_idx: list[int] = [] - previous_street: str | None = None - for name in stop_names: - street = street_cache.get(name) - if street is None: - street = provider.extract_street_name(name) - street_cache[name] = street - if street != previous_street: - segment_names.append(street) - previous_street = street - stop_to_segment_idx.append(len(segment_names) - 1) - - # Precompute future street transitions per segment - future_suffix_by_segment: list[tuple[str, ...]] = [ - ()] * len(segment_names) - future_tuple: tuple[str, ...] = () - for idx in range(len(segment_names) - 1, -1, -1): - future_suffix_by_segment[idx] = future_tuple - current_street = segment_names[idx] - future_tuple = ( - (current_street,) + future_tuple - if current_street is not None - else future_tuple - ) - - segment_future_lists: dict[int, list[str]] = {} - - first_stop_time, first_stop = trip_stop_pairs[0] - last_stop_time, last_stop = trip_stop_pairs[-1] - - starting_stop_name = first_stop.stop_name if first_stop else "Unknown Stop" - terminus_stop_name = last_stop.stop_name if last_stop else "Unknown Stop" - - # Get stop codes with fallback to stop_id if stop_code is empty - if first_stop: - starting_code = get_numeric_code(first_stop.stop_code) - if not starting_code: - starting_code = first_stop_time.stop_id - else: - starting_code = "" - - if last_stop: - terminus_code = get_numeric_code(last_stop.stop_code) - if not terminus_code: - terminus_code = last_stop_time.stop_id - else: - terminus_code = "" - - starting_name = normalise_stop_name(starting_stop_name) - terminus_name = normalise_stop_name(terminus_stop_name) - starting_time = first_stop_time.departure_time - terminus_time = last_stop_time.arrival_time - - # Determine processing passes for this trip - passes = [] - if is_active: - passes.append("current") - if is_prev: - passes.append("previous") - - for mode in passes: - is_current_mode = (mode == "current") - - for i, (stop_time, _) in enumerate(trip_stop_pairs): - # Skip the last stop of the trip (terminus) to avoid duplication - if i == len(trip_stop_pairs) - 1: - continue - - stop_code = stop_id_to_code.get(stop_time.stop_id) - - if not stop_code: - continue # Skip stops without a code - - dep_time = stop_time.departure_time - - if not is_current_mode: - # Previous day service: only include if calling_time >= 24:00:00 (night services rolling to this day) - if not is_next_day_service(dep_time): - continue - - # Normalize times for display on current day (e.g. 25:30 -> 01:30) - final_starting_time = normalize_gtfs_time( - starting_time) - final_calling_time = normalize_gtfs_time(dep_time) - final_terminus_time = normalize_gtfs_time( - terminus_time) - # SSM should be small (early morning) - final_calling_ssm = time_to_seconds(final_calling_time) - else: - # Current day service: include ALL times - # Keep times as is (e.g. 25:30 stays 25:30) - final_starting_time = format_gtfs_time(starting_time) - final_calling_time = format_gtfs_time(dep_time) - final_terminus_time = format_gtfs_time(terminus_time) - # SSM should be large if > 24:00 - final_calling_ssm = time_to_seconds(dep_time) - - if stop_code not in stop_arrivals: - stop_arrivals[stop_code] = [] - - if segment_names: - segment_idx = stop_to_segment_idx[i] - if segment_idx not in segment_future_lists: - segment_future_lists[segment_idx] = list( - future_suffix_by_segment[segment_idx] - ) - next_streets = segment_future_lists[segment_idx].copy() - else: - next_streets = [] - - # Format IDs and route using provider-specific logic - service_id_fmt = provider.format_service_id(service_id) - trip_id_fmt = provider.format_trip_id(trip_id) - route_fmt = provider.format_route( - trip_headsign, terminus_name) - - # Get previous trip shape_id if available - previous_trip_shape_id = trip_previous_shape_map.get( - trip_id, "") - - stop_arrivals[stop_code].append( - { - "service_id": service_id_fmt, - "trip_id": trip_id_fmt, - "line": route_short_name, - "route": route_fmt, - "shape_id": getattr(trip, "shape_id", ""), - "stop_sequence": stop_time.stop_sequence, - "shape_dist_traveled": getattr( - stop_time, "shape_dist_traveled", 0 - ), - "next_streets": [s for s in next_streets if s != ""], - "starting_code": starting_code, - "starting_name": starting_name, - "starting_time": final_starting_time, - "calling_time": final_calling_time, - "calling_ssm": final_calling_ssm, - "terminus_code": terminus_code, - "terminus_name": terminus_name, - "terminus_time": final_terminus_time, - "previous_trip_shape_id": previous_trip_shape_id, - } - ) - - # Sort each stop's arrivals by arrival time - for stop_code in stop_arrivals: - # Filter out entries with None arrival_seconds - stop_arrivals[stop_code] = [ - item for item in stop_arrivals[stop_code] if item["calling_ssm"] is not None - ] - stop_arrivals[stop_code].sort(key=lambda x: x["calling_ssm"]) - - return stop_arrivals - - -def process_date( - feed_dir: str, date: str, output_dir: str, provider -) -> tuple[str, Dict[str, int]]: - """ - Process a single date and write its stop JSON files. - Returns summary data for index generation. - """ - logger = get_logger(f"stop_report_{date}") - try: - logger.info(f"Starting stop report generation for date {date}") - - stops_by_code = get_all_stops_by_code(feed_dir) - - # Get all stop arrivals for the current date - stop_arrivals = get_stop_arrivals(feed_dir, date, provider) - - if not stop_arrivals: - logger.warning(f"No stop arrivals found for date {date}") - return date, {} - - logger.info( - f"Writing stop reports for {len(stop_arrivals)} stops for date {date}" - ) - - # Write individual stop JSON files - writing_start_time = time.perf_counter() - for stop_code, arrivals in stop_arrivals.items(): - write_stop_json(output_dir, date, stop_code, arrivals) - writing_end_time = time.perf_counter() - writing_elapsed = writing_end_time - writing_start_time - - logger.info( - f"Finished writing stop JSON reports for date {date} in {writing_elapsed:.2f}s" - ) - - # Write individual stop JSON files - writing_start_time = time.perf_counter() - for stop_code, arrivals in stop_arrivals.items(): - stop_by_code = stops_by_code.get(stop_code) - - if stop_by_code is not None: - write_stop_protobuf( - output_dir, - date, - stop_code, - arrivals, - stop_by_code.stop_25829_x or 0.0, - stop_by_code.stop_25829_y or 0.0, - ) - - writing_end_time = time.perf_counter() - writing_elapsed = writing_end_time - writing_start_time - - logger.info( - f"Finished writing stop protobuf reports for date {date} in {writing_elapsed:.2f}s" - ) - - logger.info(f"Processed {len(stop_arrivals)} stops for date {date}") - - stop_summary = { - stop_code: len(arrivals) for stop_code, arrivals in stop_arrivals.items() - } - return date, stop_summary - except Exception as e: - logger.error(f"Error processing date {date}: {e}") - raise - - -def main(): - args = parse_args() - output_dir = args.output_dir - feed_url = args.feed_url - - # Get provider configuration - try: - provider = get_provider(args.provider) - logger.info(f"Using provider: {args.provider}") - except ValueError as e: - logger.error(str(e)) - sys.exit(1) - - if not feed_url: - feed_dir = args.feed_dir - else: - logger.info(f"Downloading GTFS feed from {feed_url}...") - feed_dir = download_feed_from_url( - feed_url, output_dir, args.force_download) - if feed_dir is None: - logger.info("Download was skipped (feed not modified). Exiting.") - return - - all_dates = get_all_feed_dates(feed_dir) - if not all_dates: - logger.error("No valid dates found in feed.") - return - date_list = all_dates - - # Ensure date_list is not empty before processing - if not date_list: - logger.error("No valid dates to process.") - return - - logger.info(f"Processing {len(date_list)} dates") - - # Dictionary to store summary data for index files - all_stops_summary = {} - - for date in date_list: - _, stop_summary = process_date(feed_dir, date, output_dir, provider) - all_stops_summary[date] = stop_summary - - logger.info( - "Finished processing all dates. Beginning with shape transformation.") - - # Process shapes, converting each coordinate to EPSG:25829 and saving as Protobuf - process_shapes(feed_dir, output_dir) - - logger.info("Finished processing shapes.") - - if feed_url: - if os.path.exists(feed_dir): - shutil.rmtree(feed_dir) - logger.info(f"Removed temporary feed directory: {feed_dir}") - - -if __name__ == "__main__": - try: - main() - except Exception as e: - logger = get_logger("stop_report") - logger.critical(f"An unexpected error occurred: {e}", exc_info=True) - traceback.print_exc() - sys.exit(1) diff --git a/src/gtfs_vigo_stops/uv.lock b/src/gtfs_vigo_stops/uv.lock deleted file mode 100644 index bf7b7bd..0000000 --- a/src/gtfs_vigo_stops/uv.lock +++ /dev/null @@ -1,253 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "certifi" -version = "2025.4.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "gtfs-vigo" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "colorama" }, - { name = "jinja2" }, - { name = "protobuf" }, - { name = "pyproj" }, - { name = "pytest" }, - { name = "requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "colorama", specifier = ">=0.4.6" }, - { name = "jinja2", specifier = ">=3.1.6" }, - { name = "protobuf", specifier = ">=5.29.1" }, - { name = "pyproj", specifier = ">=3.7.2" }, - { name = "pytest", specifier = ">=8.4.1" }, - { name = "requests", specifier = ">=2.32.3" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "protobuf" -version = "6.33.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432, upload-time = "2025-11-13T16:44:18.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593, upload-time = "2025-11-13T16:44:06.275Z" }, - { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883, upload-time = "2025-11-13T16:44:09.222Z" }, - { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522, upload-time = "2025-11-13T16:44:10.475Z" }, - { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445, upload-time = "2025-11-13T16:44:11.869Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161, upload-time = "2025-11-13T16:44:12.778Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171, upload-time = "2025-11-13T16:44:14.035Z" }, - { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477, upload-time = "2025-11-13T16:44:17.633Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pyproj" -version = "3.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, - { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, - { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, - { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, - { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, - { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, - { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, - { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, - { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, - { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, - { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" }, - { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" }, - { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" }, - { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" }, - { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" }, - { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" }, - { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" }, - { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, -] - -[[package]] -name = "requests" -version = "2.32.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, -] - -[[package]] -name = "urllib3" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, -] -- cgit v1.3