diff --git a/camply/cli.py b/camply/cli.py index 0b2330c1..2be30ae7 100644 --- a/camply/cli.py +++ b/camply/cli.py @@ -24,6 +24,7 @@ from rich_click import RichCommand, RichGroup, rich_click from camply import Yellowstone, __application__, __version__ +from camply.exceptions import CamplyError from camply.config import EquipmentOptions, SearchConfig, logging_config from camply.config.logging_config import set_up_logging from camply.containers import SearchWindow @@ -496,6 +497,38 @@ def campgrounds( metavar="TEXT", help="Day(s) of the Week to search.", ) +near_argument = click.option( + "--near", + default=None, + help="Search for campgrounds near a place name (geocoded via Nominatim). " + "Mutually exclusive with --latitude/--longitude.", +) +latitude_argument = click.option( + "--latitude", + default=None, + type=float, + help="Center latitude for radius search (decimal degrees).", +) +longitude_argument = click.option( + "--longitude", + default=None, + type=float, + help="Center longitude for radius search (decimal degrees).", +) +radius_argument = click.option( + "--radius", + default=None, + type=float, + help="Search radius in miles. Required when --near, --latitude, or --longitude is used.", +) +exclude_type_argument = click.option( + "--exclude-type", + default=None, + multiple=True, + help="Exclude campsites or facilities whose type/name contains this string " + "(case-insensitive). Repeatable or comma-separated. " + "E.g. --exclude-type group,horse", +) def _get_equipment(equipment: Optional[List[str]]) -> List[Tuple[str, Optional[int]]]: @@ -701,6 +734,11 @@ def _get_provider_kwargs_from_cli( @notify_first_try_argument @equipment_argument @equipment_id_argument +@near_argument +@latitude_argument +@longitude_argument +@radius_argument +@exclude_type_argument @provider_argument @debug_option @click.pass_obj @@ -727,6 +765,11 @@ def campsites( equipment: Tuple[Union[str, int]], equipment_id: Tuple[Union[str, int]], day: Optional[Tuple[str]], + near: Optional[str] = None, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + radius: Optional[float] = None, + exclude_type: Tuple[str] = (), ) -> None: """ Find Available Campsites with Custom Search Criteria @@ -741,11 +784,126 @@ def campsites( if context.debug is None: context.debug = debug _set_up_debug(debug=context.debug) + + # --- geo validation --- + any_geo = near is not None or latitude is not None or longitude is not None + if near is not None and (latitude is not None or longitude is not None): + logger.error("--near is mutually exclusive with --latitude/--longitude.") + sys.exit(1) + if any_geo and radius is None: + logger.error("--radius is required when using --near, --latitude, or --longitude.") + sys.exit(1) + if any_geo and provider == "GoingToCamp": + logger.error("GoingToCamp does not support geo-based search (no coordinate data).") + sys.exit(1) + + excluded_campsite_types = [ + t.strip() for val in exclude_type for t in val.split(",") if t.strip() + ] if exclude_type else [] + if yaml_config is not None: provider, provider_kwargs, search_kwargs = yaml_utils.yaml_file_to_arguments( file_path=yaml_config ) provider = _preferred_provider(context, provider) + if excluded_campsite_types: + provider_kwargs["excluded_campsite_types"] = excluded_campsite_types + # Extract geo fields added by yaml_file_to_arguments + _yaml_near = provider_kwargs.pop("near", None) + _yaml_lat = provider_kwargs.pop("latitude", None) + _yaml_lon = provider_kwargs.pop("longitude", None) + _yaml_radius = provider_kwargs.pop("radius", None) + if _yaml_near is not None or _yaml_lat is not None or _yaml_lon is not None: + if _yaml_radius is None: + logger.error( + "radius is required in YAML config when using near/latitude/longitude." + ) + sys.exit(1) + if _yaml_near is not None: + from camply.utils.geo_utils import geocode_location + _resolved_lat, _resolved_lon = geocode_location(_yaml_near) + else: + if _yaml_lat is None or _yaml_lon is None: + logger.error( + "Both latitude and longitude are required together in YAML config." + ) + sys.exit(1) + _resolved_lat, _resolved_lon = _yaml_lat, _yaml_lon + provider_kwargs.pop("recreation_area", None) + provider_kwargs.pop("campgrounds", None) + provider_kwargs.pop("campsites", None) + from camply.search.search_geo import SearchGeo + camping_finder = SearchGeo( + latitude=_resolved_lat, + longitude=_resolved_lon, + radius_miles=_yaml_radius, + provider_filter=provider, + **provider_kwargs, + ) + camping_finder.get_matching_campsites(**search_kwargs) + return + elif any_geo: + # Resolve coordinates + if near is not None: + from camply.utils.geo_utils import geocode_location + resolved_lat, resolved_lon = geocode_location(near) + else: + if latitude is None or longitude is None: + logger.error("Both --latitude and --longitude are required together.") + sys.exit(1) + resolved_lat, resolved_lon = latitude, longitude + + # Build kwargs directly (bypassing _get_provider_kwargs_from_cli to avoid + # RecreationDotGov-specific validation that requires rec_area/campground) + search_windows = handle_search_windows(start_date=start_date, end_date=end_date) + days_of_the_week = ( + {days_of_the_week_mapping[d] for d in day} if day else None + ) + _notifications = make_list(notifications) if notifications else ["silent"] + _polling_interval = float( + polling_interval or SearchConfig.RECOMMENDED_POLLING_INTERVAL + ) + _notify_first_try = notify_first_try is not None + _search_forever = search_forever is not None + _continuous = continuous or any( + [ + len(_notifications) > 0 and _notifications != ["silent"], + _search_forever, + _notify_first_try, + polling_interval is not None, + search_once, + ] + ) + provider_kwargs = { + "search_window": search_windows, + "weekends_only": weekends, + "nights": int(nights), + "offline_search": offline_search, + "offline_search_path": offline_search_path, + "days_of_the_week": days_of_the_week, + } + search_kwargs = { + "log": True, + "verbose": True, + "continuous": _continuous, + "polling_interval": _polling_interval, + "notify_first_try": _notify_first_try, + "notification_provider": _notifications, + "search_forever": _search_forever, + "search_once": search_once, + } + + from camply.search.search_geo import SearchGeo + camping_finder = SearchGeo( + latitude=resolved_lat, + longitude=resolved_lon, + radius_miles=radius, + provider_filter=provider, + excluded_campsite_types=excluded_campsite_types, + **provider_kwargs, + ) + camping_finder.get_matching_campsites(**search_kwargs) + return else: provider = _preferred_provider(context, provider) provider_kwargs, search_kwargs = _get_provider_kwargs_from_cli( @@ -770,6 +928,9 @@ def campsites( day=day, yaml_config=yaml_config, ) + if excluded_campsite_types: + provider_kwargs["excluded_campsite_types"] = excluded_campsite_types + provider_class: Type[BaseCampingSearch] = CAMPSITE_SEARCH_PROVIDER[provider] camping_finder: BaseCampingSearch = provider_class(**provider_kwargs) camping_finder.get_matching_campsites(**search_kwargs) @@ -883,6 +1044,9 @@ def cli(): camply_command_line() except KeyboardInterrupt: logger.debug("Handling Exit Request") + except CamplyError as e: + logger.error(str(e)) + sys.exit(1) finally: logger.camply("Exiting camply 👋") diff --git a/camply/containers/api_responses.py b/camply/containers/api_responses.py index 68919bd4..70650a69 100644 --- a/camply/containers/api_responses.py +++ b/camply/containers/api_responses.py @@ -329,6 +329,8 @@ class FacilityResponse(CamplyModel): FacilityTypeDescription: str Enabled: bool Reservable: bool + FacilityLatitude: Optional[float] = None + FacilityLongitude: Optional[float] = None FACILITYADDRESS: Optional[List[_FacilityAddress]] RECAREA: Optional[List[_FacilityRecArea]] ORGANIZATION: Optional[List[_FacilityOrganization]] diff --git a/camply/containers/data_containers.py b/camply/containers/data_containers.py index fcc184b7..ce414f2e 100644 --- a/camply/containers/data_containers.py +++ b/camply/containers/data_containers.py @@ -104,6 +104,7 @@ class AvailableCampsite(CamplyModel): facility_id: Union[int, str] booking_url: str location: Optional[CampsiteLocation] = None + distance_miles: Optional[float] = None permitted_equipment: Optional[List[RecDotGovEquipment]] campsite_attributes: Optional[List[RecDotGovAttribute]] diff --git a/camply/containers/search_model.py b/camply/containers/search_model.py index 34414e18..f8a0108a 100644 --- a/camply/containers/search_model.py +++ b/camply/containers/search_model.py @@ -56,6 +56,11 @@ class YamlSearchFile(CamplyModel): equipment: ArrayOrSingleEquipment = None offline_search: bool = False offline_search_path: Optional[str] = None + near: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + radius: Optional[float] = None + excluded_campsite_types: Optional[List[str]] = None @validator("provider", pre=True) def validate_provider(cls, value): diff --git a/camply/notifications/email_notifications.py b/camply/notifications/email_notifications.py index 44270291..536155d3 100755 --- a/camply/notifications/email_notifications.py +++ b/camply/notifications/email_notifications.py @@ -3,11 +3,12 @@ """ import logging from email.message import EmailMessage -from smtplib import SMTP_SSL +from smtplib import SMTP_SSL, SMTPException from typing import List from camply.config import EmailConfig from camply.containers import AvailableCampsite +from camply.exceptions import CamplyError from camply.notifications.base_notifications import BaseNotifications logger = logging.getLogger(__name__) @@ -55,16 +56,22 @@ def __init__(self): logger.error(error_message) raise EnvironmentError(error_message) # ATTEMPT AN EMAIL LOGIN AT INIT TO THROW ERRORS EARLY - _email_server = SMTP_SSL( - self.email_smtp_server, - self.email_smtp_server_port, - ) - _email_server.ehlo() - _email_server.login( - user=self.email_username, - password=self._email_password, - ) - _email_server.quit() + try: + _email_server = SMTP_SSL( + self.email_smtp_server, + self.email_smtp_server_port, + ) + _email_server.ehlo() + _email_server.login( + user=self.email_username, + password=self._email_password, + ) + _email_server.quit() + except SMTPException as e: + raise CamplyError( + f"Email authentication failed for {self.email_username!r}: {e}. " + "Check your credentials or run `camply configure`." + ) from e def send_message(self, message: str, **kwargs) -> None: """ diff --git a/camply/providers/recreation_dot_gov/recdotgov_provider.py b/camply/providers/recreation_dot_gov/recdotgov_provider.py index 0fbee5df..43d075b5 100644 --- a/camply/providers/recreation_dot_gov/recdotgov_provider.py +++ b/camply/providers/recreation_dot_gov/recdotgov_provider.py @@ -212,9 +212,10 @@ def find_campgrounds( state_arg = kwargs.get("state", None) if state_arg is not None: kwargs.update({"state": state_arg.upper()}) - if search_string in ["", None] and state_arg is None: + geo_arg = kwargs.get("latitude", None) + if search_string in ["", None] and state_arg is None and geo_arg is None: raise RuntimeError( - "You must provide a search query or state to find campsites" + "You must provide a search query, state, or lat/lon to find campsites" ) if self.activity_name: kwargs["activity"] = self.activity_name @@ -493,11 +494,14 @@ def process_facilities_responses( recreation_area = facility_object.RECAREA[0].RecAreaName recreation_area_id = facility_object.RECAREA[0].RecAreaID formatted_recreation_area = f"{recreation_area}, {facility_state}" + lat = facility_object.FacilityLatitude + lon = facility_object.FacilityLongitude campground_facility = CampgroundFacility( facility_name=facility_object.FacilityName.title(), recreation_area=formatted_recreation_area, facility_id=facility_object.FacilityID, recreation_area_id=recreation_area_id, + coordinates=(lat, lon) if lat is not None and lon is not None else None, ) return facility, campground_facility except (KeyError, IndexError): diff --git a/camply/providers/usedirect/usedirect.py b/camply/providers/usedirect/usedirect.py index 65fec63d..5f8edc76 100644 --- a/camply/providers/usedirect/usedirect.py +++ b/camply/providers/usedirect/usedirect.py @@ -605,6 +605,9 @@ def _get_places(self) -> Dict[int, UseDirectDetailedPlace]: recreation_area_id=place.PlaceId, recreation_area_location=f"{place.City.title()}, {place.State}", description=place.Description, + coordinates=(place.Latitude, place.Longitude) + if place.Latitude is not None and place.Longitude is not None + else None, ) for place in places_data_validated.values() } @@ -627,7 +630,7 @@ def _get_facilities(self) -> Dict[int, UseDirectFacilityMetadata]: facilities_data: List[Dict[str, Any]] = resp.json() metadata_file.write_text(json.dumps(facilities_data, indent=2)) if not isinstance(facilities_data, list): - raise CamplyError("Unexpected data from %s", metadata_file) + raise CamplyError(f"Unexpected data from {metadata_file}") facilities_validated = [ UseDirectFacilityMetadata(**facility_json) for facility_json in facilities_data @@ -644,6 +647,7 @@ def _get_facilities(self) -> Dict[int, UseDirectFacilityMetadata]: facility_id=facility.FacilityId, recreation_area_id=facility.PlaceId, recreation_area=rec_area.recreation_area, + coordinates=rec_area.coordinates, ) return facilities_data_validated diff --git a/camply/search/__init__.py b/camply/search/__init__.py index 2b8b6d3d..53c7a81c 100644 --- a/camply/search/__init__.py +++ b/camply/search/__init__.py @@ -27,6 +27,7 @@ SearchReserveCalifornia, SearchVirginiaStateParks, ) +from camply.search.search_geo import SearchGeo from camply.search.search_yellowstone import SearchYellowstone # Register Providers Here with their Search class diff --git a/camply/search/base_search.py b/camply/search/base_search.py index 6b556c62..1fca87e6 100644 --- a/camply/search/base_search.py +++ b/camply/search/base_search.py @@ -50,6 +50,7 @@ def __init__( offline_search: bool = False, offline_search_path: Optional[str] = None, days_of_the_week: Optional[Sequence[int]] = None, + excluded_campsite_types: Optional[List[str]] = None, **kwargs, ) -> None: """ @@ -118,6 +119,7 @@ def __init__( ] = self.load_campsites_from_file() self.loaded_campsites: Set[AvailableCampsite] = self.campsites_found.copy() self.search_attempts: int = 0 + self.excluded_campsite_types: List[str] = excluded_campsite_types or [] @property def search_days(self) -> List[datetime]: @@ -252,6 +254,15 @@ def _search_matching_campsites_available( ] ): matching_campgrounds.append(camp) + if self.excluded_campsite_types: + matching_campgrounds = [ + c for c in matching_campgrounds + if not any( + excl.lower() in (c.campsite_type or "").lower() + or excl.lower() in (c.facility_name or "").lower() + for excl in self.excluded_campsite_types + ) + ] logger.info( f"{(get_emoji(matching_campgrounds) + ' ') * 4}{len(matching_campgrounds)} " "Reservable Campsites Matching Search Preferences" @@ -880,9 +891,16 @@ def _log_availabilities( for location_tuple, campground_availability in available_sites.groupby( [DataColumns.RECREATION_AREA_COLUMN, DataColumns.FACILITY_NAME_COLUMN] ): + dist_col = campground_availability.get("distance_miles") + dist_val = ( + dist_col.dropna().iloc[0] + if dist_col is not None and not dist_col.dropna().empty + else None + ) + dist_str = f" ({dist_val:.1f} mi)" if dist_val is not None else "" logger.info( f"\t⛰️ {' 🏕 '.join(location_tuple)}: ⛺ " - f"{len(campground_availability)} sites" + f"{len(campground_availability)} sites{dist_str}" ) if verbose is True: for ( diff --git a/camply/search/search_geo.py b/camply/search/search_geo.py new file mode 100644 index 00000000..29e0deff --- /dev/null +++ b/camply/search/search_geo.py @@ -0,0 +1,236 @@ +""" +Geo-based multi-provider campsite search +""" + +import logging +import sys +from typing import Dict, List, Optional, Tuple, Type, Union + +from camply.containers import AvailableCampsite, CampgroundFacility, SearchWindow +from camply.exceptions import CamplyError +from camply.providers import RecreationDotGov +from camply.providers.xanterra.yellowstone_lodging import Yellowstone +from camply.search.base_search import BaseCampingSearch +from camply.search.search_recreationdotgov import SearchRecreationDotGov +from camply.search.search_usedirect import ( + SearchAlabamaStateParks, + SearchArizonaStateParks, + SearchFairfaxCountyParks, + SearchFloridaStateParks, + SearchMaricopaCountyParks, + SearchMinnesotaStateParks, + SearchMissouriStateParks, + SearchNorthernTerritory, + SearchOhioStateParks, + SearchOregonMetro, + SearchReserveCalifornia, + SearchVirginiaStateParks, +) +from camply.search.search_yellowstone import SearchYellowstone +from camply.utils.geo_utils import geocode_location, haversine_distance_miles + +logger = logging.getLogger(__name__) + +# (latitude, longitude) of Yellowstone's geographic center +_YELLOWSTONE_CENTER: Tuple[float, float] = (44.4280, -110.5885) + +# UseDirect search classes eligible for geo-search (all have lat/lon in cached metadata) +_USEDIRECT_SEARCH_CLASSES = [ + SearchReserveCalifornia, + SearchAlabamaStateParks, + SearchArizonaStateParks, + SearchFloridaStateParks, + SearchMinnesotaStateParks, + SearchMissouriStateParks, + SearchOhioStateParks, + SearchOregonMetro, + SearchVirginiaStateParks, + SearchFairfaxCountyParks, + SearchMaricopaCountyParks, + SearchNorthernTerritory, +] + + +class SearchGeo(BaseCampingSearch): + """ + Multi-provider campsite search by distance from a geographic point. + + Fans out across RecreationDotGov, all UseDirect providers, and Yellowstone. + """ + + provider_class = RecreationDotGov + + def __init__( + self, + search_window: Union[SearchWindow, List[SearchWindow]], + latitude: float, + longitude: float, + radius_miles: float, + provider_filter: Optional[str] = None, + weekends_only: bool = False, + nights: int = 1, + excluded_campsite_types: Optional[List[str]] = None, + **kwargs, + ) -> None: + super().__init__( + search_window=search_window, + weekends_only=weekends_only, + nights=nights, + excluded_campsite_types=excluded_campsite_types, + **kwargs, + ) + self.latitude = latitude + self.longitude = longitude + self.radius_miles = radius_miles + self._facility_distances: Dict[str, float] = {} + self._sub_searches: List[BaseCampingSearch] = self._build_sub_searches( + search_window=search_window, + provider_filter=provider_filter, + weekends_only=weekends_only, + nights=nights, + **kwargs, + ) + self.campgrounds = [cg for s in self._sub_searches for cg in s.campgrounds] + if not self.campgrounds: + logger.error( + f"No campgrounds found within {radius_miles} miles of " + f"({latitude:.4f}, {longitude:.4f})" + ) + sys.exit(1) + + def _build_sub_searches( + self, + search_window: Union[SearchWindow, List[SearchWindow]], + provider_filter: Optional[str], + weekends_only: bool, + nights: int, + **kwargs, + ) -> List[BaseCampingSearch]: + """Discover campgrounds within radius from each applicable provider.""" + sub_searches: List[BaseCampingSearch] = [] + shared = dict( + search_window=search_window, + weekends_only=weekends_only, + nights=nights, + ) + + # RecreationDotGov + if provider_filter is None or provider_filter == RecreationDotGov.__name__: + sub_searches += self._build_recdotgov_search(shared, **kwargs) + + # UseDirect variants + for search_cls in _USEDIRECT_SEARCH_CLASSES: + provider_name = search_cls.provider_class.__name__ + if provider_filter is None or provider_filter == provider_name: + sub_searches += self._build_usedirect_search(search_cls, shared, **kwargs) + + # Yellowstone + if provider_filter is None or provider_filter == Yellowstone.__name__: + sub_searches += self._build_yellowstone_search(shared, **kwargs) + + return sub_searches + + def _build_recdotgov_search( + self, shared: dict, **kwargs + ) -> List[SearchRecreationDotGov]: + provider = RecreationDotGov() + campgrounds = provider.find_campgrounds( + latitude=self.latitude, + longitude=self.longitude, + radius=self.radius_miles, + ) + if not campgrounds: + return [] + campground_ids = [] + for cg in campgrounds: + campground_ids.append(int(cg.facility_id)) + if cg.coordinates is not None: + self._facility_distances[str(cg.facility_id)] = haversine_distance_miles( + self.latitude, self.longitude, cg.coordinates[0], cg.coordinates[1] + ) + logger.info( + f"RecreationDotGov: {len(campground_ids)} campgrounds within " + f"{self.radius_miles} miles" + ) + return [ + SearchRecreationDotGov( + campgrounds=campground_ids, + **shared, + **kwargs, + ) + ] + + def _build_usedirect_search( + self, + search_cls: Type[BaseCampingSearch], + shared: dict, + **kwargs, + ) -> List[BaseCampingSearch]: + provider = search_cls.provider_class() + try: + provider.refresh_metadata() + except Exception as exc: + logger.warning( + f"{search_cls.provider_class.__name__}: skipping — metadata unavailable: {exc}" + ) + return [] + in_radius = [] + for cg in provider.usedirect_campgrounds.values(): + if cg.coordinates is None: + continue + dist = haversine_distance_miles( + self.latitude, self.longitude, cg.coordinates[0], cg.coordinates[1] + ) + if dist <= self.radius_miles: + in_radius.append(int(cg.facility_id)) + self._facility_distances[str(cg.facility_id)] = dist + if not in_radius: + return [] + logger.info( + f"{search_cls.provider_class.__name__}: {len(in_radius)} campgrounds " + f"within {self.radius_miles} miles" + ) + return [ + search_cls( + recreation_area=[], + campgrounds=in_radius, + **shared, + **kwargs, + ) + ] + + def _build_yellowstone_search( + self, shared: dict, **kwargs + ) -> List[SearchYellowstone]: + dist = haversine_distance_miles( + self.latitude, + self.longitude, + _YELLOWSTONE_CENTER[0], + _YELLOWSTONE_CENTER[1], + ) + if dist > self.radius_miles: + return [] + logger.info( + f"Yellowstone: park center is {dist:.1f} miles away — including in search" + ) + self._facility_distances["_yellowstone"] = dist + return [SearchYellowstone(**shared, **kwargs)] + + def get_all_campsites(self) -> List[AvailableCampsite]: + """Aggregate available campsites from all sub-searches.""" + results: List[AvailableCampsite] = [] + for sub_search in self._sub_searches: + is_yellowstone = isinstance(sub_search, SearchYellowstone) + yellowstone_dist = self._facility_distances.get("_yellowstone") + for campsite in sub_search.get_all_campsites(): + if is_yellowstone and yellowstone_dist is not None: + dist = yellowstone_dist + else: + dist = self._facility_distances.get(str(campsite.facility_id)) + if dist is not None: + campsite = campsite.copy(update={"distance_miles": dist}) + results.append(campsite) + return results + + def list_campsite_units(self) -> None: + raise NotImplementedError diff --git a/camply/utils/geo_utils.py b/camply/utils/geo_utils.py new file mode 100644 index 00000000..36c6f1e7 --- /dev/null +++ b/camply/utils/geo_utils.py @@ -0,0 +1,35 @@ +""" +Geo-coding and distance utilities +""" + +import math +from typing import Tuple + +from geopy.geocoders import Nominatim + +from camply.exceptions import CamplyError + +_EARTH_RADIUS_MILES = 3958.8 +_GEOCODER = Nominatim(user_agent="camply") + + +def haversine_distance_miles( + lat1: float, lon1: float, lat2: float, lon2: float +) -> float: + """Return the great-circle distance in miles between two lat/lon points.""" + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + return _EARTH_RADIUS_MILES * 2 * math.asin(math.sqrt(a)) + + +def geocode_location(place_name: str) -> Tuple[float, float]: + """Convert a place name to (latitude, longitude) using Nominatim. + + Raises CamplyError if the place cannot be geocoded. + """ + location = _GEOCODER.geocode(place_name) + if location is None: + raise CamplyError(f"Could not geocode location: {place_name!r}") + return location.latitude, location.longitude diff --git a/camply/utils/yaml_utils.py b/camply/utils/yaml_utils.py index 9c5767df..c606d83d 100644 --- a/camply/utils/yaml_utils.py +++ b/camply/utils/yaml_utils.py @@ -125,6 +125,11 @@ def yaml_file_to_arguments( "equipment": equipment, "offline_search": yaml_model.offline_search, "offline_search_path": yaml_model.offline_search_path, + "near": yaml_model.near, + "latitude": yaml_model.latitude, + "longitude": yaml_model.longitude, + "radius": yaml_model.radius, + "excluded_campsite_types": yaml_model.excluded_campsite_types, } search_kwargs = { "log": True, diff --git a/pyproject.toml b/pyproject.toml index 404b0d92..bf1378b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ dependencies = [ "click~=8.1.3", "fake-useragent~=1.4.0", + "geopy>=2.3,<3", "pandas>=2,<3", "pydantic~=1.10.22", "python-dotenv~=1.0.0", diff --git a/tests/cli/test_campsites.py b/tests/cli/test_campsites.py index ea2d27b1..56045f30 100644 --- a/tests/cli/test_campsites.py +++ b/tests/cli/test_campsites.py @@ -578,3 +578,35 @@ def test_search_by_yaml_reservecalifornia( assert "Andrew Molera SP" in result.output assert "Reservable Campsites Matching Search Preferences" in result.output cli_status_checker(result=result, exit_code_zero=True) + + +def test_geo_near_and_lat_lon_mutually_exclusive(cli_runner: CamplyRunner) -> None: + """ + --near and --latitude/--longitude are mutually exclusive + """ + test_command = """ + camply campsites \ + --near "San Francisco, CA" \ + --latitude 37.77 \ + --radius 100 \ + --start-date 2026-07-10 \ + --end-date 2026-07-14 + """ + result = cli_runner.run_camply_command(command=test_command) + assert "mutually exclusive" in result.output + cli_status_checker(result=result, exit_code_zero=False) + + +def test_geo_radius_required(cli_runner: CamplyRunner) -> None: + """ + --radius is required when any geo param is provided + """ + test_command = """ + camply campsites \ + --near "San Francisco, CA" \ + --start-date 2026-07-10 \ + --end-date 2026-07-14 + """ + result = cli_runner.run_camply_command(command=test_command) + assert "radius" in result.output.lower() + cli_status_checker(result=result, exit_code_zero=False) diff --git a/tests/search_providers/test_geo_search.py b/tests/search_providers/test_geo_search.py new file mode 100644 index 00000000..e9403f0f --- /dev/null +++ b/tests/search_providers/test_geo_search.py @@ -0,0 +1,145 @@ +""" +Tests for SearchGeo and excluded_campsite_types filtering +""" +import pytest +from datetime import datetime +from unittest.mock import MagicMock, patch + +from camply.containers import AvailableCampsite, SearchWindow +from camply.search.search_geo import SearchGeo, _YELLOWSTONE_CENTER + + +@pytest.fixture +def search_window() -> SearchWindow: + return SearchWindow( + start_date=datetime(2026, 7, 1), + end_date=datetime(2026, 7, 15), + ) + + +# --- Type exclusion filter tests --- + + +class TestExcludedCampsiteTypes: + def test_excluded_type_removed(self, available_campsite): + """A campsite with a matching type is removed by the filter.""" + group_campsite = available_campsite.copy(update={"campsite_type": "Group Standard"}) + excluded = ["group"] + result = [ + c + for c in [available_campsite, group_campsite] + if not any( + excl.lower() in (c.campsite_type or "").lower() for excl in excluded + ) + ] + assert available_campsite in result + assert group_campsite not in result + + def test_exclusion_is_case_insensitive(self, available_campsite): + """Exclusion matching ignores case.""" + horse_campsite = available_campsite.copy(update={"campsite_type": "HORSE ONLY"}) + excluded = ["horse"] + result = [ + c + for c in [horse_campsite] + if not any( + excl.lower() in (c.campsite_type or "").lower() for excl in excluded + ) + ] + assert result == [] + + def test_none_campsite_type_not_excluded(self, available_campsite): + """Campsites with no type are never excluded.""" + none_type_campsite = available_campsite.copy(update={"campsite_type": None}) + excluded = ["group"] + result = [ + c + for c in [none_type_campsite] + if not any( + excl.lower() in (c.campsite_type or "").lower() for excl in excluded + ) + ] + assert none_type_campsite in result + + def test_empty_exclusion_list_passes_all(self, available_campsite): + """An empty exclusion list keeps all campsites.""" + group_campsite = available_campsite.copy(update={"campsite_type": "Group Standard"}) + excluded: list = [] + result = [ + c + for c in [available_campsite, group_campsite] + if not any( + excl.lower() in (c.campsite_type or "").lower() for excl in excluded + ) + ] + assert len(result) == 2 + + def test_non_matching_type_kept(self, available_campsite): + """A campsite whose type does not match any exclusion is kept.""" + excluded = ["group"] + result = [ + c + for c in [available_campsite] + if not any( + excl.lower() in (c.campsite_type or "").lower() for excl in excluded + ) + ] + assert available_campsite in result # campsite_type="Test" does not contain "group" + + +# --- SearchGeo construction tests --- + + +class TestSearchGeoYellowstone: + def test_yellowstone_excluded_when_far(self, search_window): + """Yellowstone is not included when the search center is far from the park.""" + searcher = SearchGeo.__new__(SearchGeo) + searcher.latitude = 37.77 # San Francisco — ~1400 miles from Yellowstone + searcher.longitude = -122.41 + searcher.radius_miles = 100.0 + + result = searcher._build_yellowstone_search( + shared=dict( + search_window=search_window, + weekends_only=False, + nights=1, + ) + ) + assert result == [] + + def test_yellowstone_included_when_near(self, search_window): + """Yellowstone is included when the search center is within radius.""" + searcher = SearchGeo.__new__(SearchGeo) + searcher.latitude = _YELLOWSTONE_CENTER[0] + 0.1 # just north of center + searcher.longitude = _YELLOWSTONE_CENTER[1] + searcher.radius_miles = 20.0 + + with patch( + "camply.search.search_geo.SearchYellowstone.__init__", + return_value=None, + ): + result = searcher._build_yellowstone_search( + shared=dict( + search_window=search_window, + weekends_only=False, + nights=1, + ) + ) + assert len(result) == 1 + + +class TestSearchGeoNoCampgrounds: + def test_no_campgrounds_found_exits(self, search_window): + """SearchGeo exits with sys.exit(1) when no campgrounds are found within radius.""" + with patch.object( + SearchGeo, + "_build_sub_searches", + return_value=[], + ): + with pytest.raises(SystemExit): + SearchGeo( + search_window=search_window, + latitude=37.77, + longitude=-122.41, + radius_miles=1.0, + ) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/test_geo_utils.py b/tests/utils/test_geo_utils.py new file mode 100644 index 00000000..0dce16d0 --- /dev/null +++ b/tests/utils/test_geo_utils.py @@ -0,0 +1,41 @@ +""" +Tests for geo_utils +""" +import pytest + +from camply.utils.geo_utils import haversine_distance_miles, geocode_location + + +def test_haversine_same_point(): + assert haversine_distance_miles(37.77, -122.41, 37.77, -122.41) == pytest.approx(0.0, abs=0.001) + + +def test_haversine_sf_to_la(): + # San Francisco to Los Angeles is ~347 miles + result = haversine_distance_miles(37.7749, -122.4194, 34.0522, -118.2437) + assert 340 < result < 360 + + +def test_haversine_sf_to_nyc(): + # SF to NYC is ~2570 miles + result = haversine_distance_miles(37.7749, -122.4194, 40.7128, -74.0060) + assert 2500 < result < 2650 + + +def test_geocode_returns_tuple(mocker): + mock_location = mocker.MagicMock() + mock_location.latitude = 37.7749 + mock_location.longitude = -122.4194 + mocker.patch( + "camply.utils.geo_utils.Nominatim.geocode", + return_value=mock_location, + ) + lat, lon = geocode_location("San Francisco, CA") + assert lat == pytest.approx(37.7749) + assert lon == pytest.approx(-122.4194) + + +def test_geocode_raises_on_not_found(mocker): + mocker.patch("camply.utils.geo_utils.Nominatim.geocode", return_value=None) + with pytest.raises(Exception, match="Could not geocode"): + geocode_location("zzzznotarealplace12345")