From 31d64f87cca4d0c42159c525a94baa194fc93101 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:43:19 -0700 Subject: [PATCH 01/15] build: add geopy dependency for geocoding support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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", From befd83f9f3d6b2e34dd411b723ae94754e20320b Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:49:37 -0700 Subject: [PATCH 02/15] feat: add geo_utils with haversine distance and Nominatim geocoding --- camply/utils/geo_utils.py | 35 ++++++++++++++++++++++++++++++ tests/utils/__init__.py | 0 tests/utils/test_geo_utils.py | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 camply/utils/geo_utils.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_geo_utils.py 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/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") From 68fbc4f2527ffef45317d796a38e7ba0f38e36a6 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:52:49 -0700 Subject: [PATCH 03/15] feat: add excluded_campsite_types filtering to BaseCampingSearch Co-Authored-By: Claude Sonnet 4.6 --- camply/search/base_search.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/camply/search/base_search.py b/camply/search/base_search.py index 6b556c62..2d723dab 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,14 @@ 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() + for excl in self.excluded_campsite_types + ) + ] logger.info( f"{(get_emoji(matching_campgrounds) + ' ') * 4}{len(matching_campgrounds)} " "Reservable Campsites Matching Search Preferences" From 5abcc347cfeda761e8cd633bf17a4a48d4e74902 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:59:14 -0700 Subject: [PATCH 04/15] feat: populate RecreationArea and CampgroundFacility coordinates in UseDirect providers --- camply/providers/usedirect/usedirect.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/camply/providers/usedirect/usedirect.py b/camply/providers/usedirect/usedirect.py index 65fec63d..03e946ab 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() } @@ -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 From f98a9a6ef84136a2295f12542a1325bcae01cba8 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:14:31 -0700 Subject: [PATCH 05/15] feat: allow RecreationDotGov find_campgrounds to accept geo lat/lon/radius kwargs --- camply/providers/recreation_dot_gov/recdotgov_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/camply/providers/recreation_dot_gov/recdotgov_provider.py b/camply/providers/recreation_dot_gov/recdotgov_provider.py index 0fbee5df..100b2466 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 From c615afc69ce6f8c71d1bce7161b44f4b2553705e Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:17:00 -0700 Subject: [PATCH 06/15] feat: add SearchGeo multi-provider geo-based campsite search orchestrator --- camply/search/__init__.py | 1 + camply/search/search_geo.py | 209 ++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 camply/search/search_geo.py diff --git a/camply/search/__init__.py b/camply/search/__init__.py index 2b8b6d3d..1360ca7c 100644 --- a/camply/search/__init__.py +++ b/camply/search/__init__.py @@ -28,6 +28,7 @@ SearchVirginiaStateParks, ) from camply.search.search_yellowstone import SearchYellowstone +from camply.search.search_geo import SearchGeo # Register Providers Here with their Search class __search_providers__: List[Type[BaseCampingSearch]] = [ diff --git a/camply/search/search_geo.py b/camply/search/search_geo.py new file mode 100644 index 00000000..11c6140b --- /dev/null +++ b/camply/search/search_geo.py @@ -0,0 +1,209 @@ +""" +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._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 = [int(cg.facility_id) for cg in campgrounds] + 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() + provider.refresh_metadata() + in_radius = [ + int(cg.facility_id) + for cg in provider.usedirect_campgrounds.values() + if cg.coordinates is not None + and haversine_distance_miles( + self.latitude, self.longitude, cg.coordinates[0], cg.coordinates[1] + ) + <= self.radius_miles + ] + 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" + ) + 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: + results.extend(sub_search.get_all_campsites()) + return results From 7bf6e3bc689360a6ee9c109983a6dcf3bc7b9047 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:21:53 -0700 Subject: [PATCH 07/15] feat: add --near/--latitude/--longitude/--radius/--exclude-type to campsites command Co-Authored-By: Claude Sonnet 4.6 --- camply/cli.py | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/camply/cli.py b/camply/cli.py index 0b2330c1..a3f5eca0 100644 --- a/camply/cli.py +++ b/camply/cli.py @@ -496,6 +496,37 @@ 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 whose type contains this string (case-insensitive, repeatable). " + "E.g. --exclude-type group --exclude-type horse", +) def _get_equipment(equipment: Optional[List[str]]) -> List[Tuple[str, Optional[int]]]: @@ -701,6 +732,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 +763,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 +782,90 @@ 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 = list(exclude_type) 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 + 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 +890,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) From 9471aa94dad5fbf1d277a9d672e4c1cbe016e06c Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:23:54 -0700 Subject: [PATCH 08/15] feat: add near/latitude/longitude/radius/excluded_campsite_types to YAML config --- camply/containers/search_model.py | 5 +++++ camply/utils/yaml_utils.py | 5 +++++ 2 files changed, 10 insertions(+) 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/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, From 20a7ef2f85bfb1fc04de1da35bb1d6b7c8a09ebe Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:25:19 -0700 Subject: [PATCH 09/15] test: add tests for SearchGeo and excluded_campsite_types filtering --- tests/search_providers/test_geo_search.py | 145 ++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/search_providers/test_geo_search.py 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, + ) From fdd23bee27b5762bd1cf0f9e30404111860c3114 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:33:27 -0700 Subject: [PATCH 10/15] fix: route YAML geo fields to SearchGeo; add CLI geo validation tests Co-Authored-By: Claude Sonnet 4.6 --- camply/cli.py | 34 ++++++++++++++++++++++++++++++++++ tests/cli/test_campsites.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/camply/cli.py b/camply/cli.py index a3f5eca0..47d2632b 100644 --- a/camply/cli.py +++ b/camply/cli.py @@ -804,6 +804,40 @@ def campsites( 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: 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) From 93e90b1c4e39283ba8793b97abf4edc1b1ad8abc Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:43:42 -0700 Subject: [PATCH 11/15] fix: correct isort order for SearchGeo import; fix CamplyError f-string in usedirect --- camply/providers/usedirect/usedirect.py | 2 +- camply/search/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/camply/providers/usedirect/usedirect.py b/camply/providers/usedirect/usedirect.py index 03e946ab..5f8edc76 100644 --- a/camply/providers/usedirect/usedirect.py +++ b/camply/providers/usedirect/usedirect.py @@ -630,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 diff --git a/camply/search/__init__.py b/camply/search/__init__.py index 1360ca7c..53c7a81c 100644 --- a/camply/search/__init__.py +++ b/camply/search/__init__.py @@ -27,8 +27,8 @@ SearchReserveCalifornia, SearchVirginiaStateParks, ) -from camply.search.search_yellowstone import SearchYellowstone from camply.search.search_geo import SearchGeo +from camply.search.search_yellowstone import SearchYellowstone # Register Providers Here with their Search class __search_providers__: List[Type[BaseCampingSearch]] = [ From f8a8700d5e2fa4a86dcbc25aa05ea821110aef27 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:34:13 -0700 Subject: [PATCH 12/15] fix: implement list_campsite_units on SearchGeo to satisfy abstract method --- camply/search/search_geo.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/camply/search/search_geo.py b/camply/search/search_geo.py index 11c6140b..3032f610 100644 --- a/camply/search/search_geo.py +++ b/camply/search/search_geo.py @@ -207,3 +207,6 @@ def get_all_campsites(self) -> List[AvailableCampsite]: for sub_search in self._sub_searches: results.extend(sub_search.get_all_campsites()) return results + + def list_campsite_units(self) -> None: + raise NotImplementedError From 7f77a0289adfc1ae8f56a36e3dbb029074147a05 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:48:16 -0700 Subject: [PATCH 13/15] fix: skip UseDirect providers whose metadata endpoint is unavailable Wraps provider.refresh_metadata() in try/except in _build_usedirect_search so that providers returning empty or malformed HTTP responses (JSONDecodeError) are logged as warnings and skipped rather than crashing the entire geo search. Co-Authored-By: Claude Sonnet 4.6 --- camply/search/search_geo.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/camply/search/search_geo.py b/camply/search/search_geo.py index 3032f610..3522f58e 100644 --- a/camply/search/search_geo.py +++ b/camply/search/search_geo.py @@ -160,7 +160,13 @@ def _build_usedirect_search( **kwargs, ) -> List[BaseCampingSearch]: provider = search_cls.provider_class() - provider.refresh_metadata() + try: + provider.refresh_metadata() + except Exception as exc: + logger.warning( + f"{search_cls.provider_class.__name__}: skipping — metadata unavailable: {exc}" + ) + return [] in_radius = [ int(cg.facility_id) for cg in provider.usedirect_campgrounds.values() From 11d4aa3ecdf84d1905da3ffa7060c3e1b99a01e3 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:24:53 -0700 Subject: [PATCH 14/15] feat: show distance in geo search results; fix exclude-type filter; comma list support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AvailableCampsite gains distance_miles field (optional, None for non-geo searches) - SearchGeo builds facility_id→distance map during provider fan-out and annotates each result in get_all_campsites() via pydantic .copy() - RecreationDotGov CampgroundFacility now captures FacilityLatitude/Longitude from RIDB API response so geo distances can be computed for recdotgov campgrounds - _log_availabilities appends "(X.X mi)" to each campground line when distance is set - exclude-type filter now also checks facility_name so --exclude-type horse removes campgrounds named "Horse Camp" (not just campsites typed HORSE) - --exclude-type accepts comma-separated values: --exclude-type group,horse Co-Authored-By: Claude Sonnet 4.6 --- camply/cli.py | 9 +++-- camply/containers/api_responses.py | 2 ++ camply/containers/data_containers.py | 1 + .../recreation_dot_gov/recdotgov_provider.py | 3 ++ camply/search/base_search.py | 10 +++++- camply/search/search_geo.py | 36 ++++++++++++++----- 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/camply/cli.py b/camply/cli.py index 47d2632b..fb054294 100644 --- a/camply/cli.py +++ b/camply/cli.py @@ -524,8 +524,9 @@ def campgrounds( "--exclude-type", default=None, multiple=True, - help="Exclude campsites whose type contains this string (case-insensitive, repeatable). " - "E.g. --exclude-type group --exclude-type horse", + help="Exclude campsites or facilities whose type/name contains this string " + "(case-insensitive). Repeatable or comma-separated. " + "E.g. --exclude-type group,horse", ) @@ -795,7 +796,9 @@ def campsites( logger.error("GoingToCamp does not support geo-based search (no coordinate data).") sys.exit(1) - excluded_campsite_types = list(exclude_type) if exclude_type else [] + 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( 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/providers/recreation_dot_gov/recdotgov_provider.py b/camply/providers/recreation_dot_gov/recdotgov_provider.py index 100b2466..43d075b5 100644 --- a/camply/providers/recreation_dot_gov/recdotgov_provider.py +++ b/camply/providers/recreation_dot_gov/recdotgov_provider.py @@ -494,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/search/base_search.py b/camply/search/base_search.py index 2d723dab..1fca87e6 100644 --- a/camply/search/base_search.py +++ b/camply/search/base_search.py @@ -259,6 +259,7 @@ def _search_matching_campsites_available( 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 ) ] @@ -890,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 index 3522f58e..29e0deff 100644 --- a/camply/search/search_geo.py +++ b/camply/search/search_geo.py @@ -82,6 +82,7 @@ def __init__( 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, @@ -140,7 +141,13 @@ def _build_recdotgov_search( ) if not campgrounds: return [] - campground_ids = [int(cg.facility_id) for cg in campgrounds] + 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" @@ -167,15 +174,16 @@ def _build_usedirect_search( f"{search_cls.provider_class.__name__}: skipping — metadata unavailable: {exc}" ) return [] - in_radius = [ - int(cg.facility_id) - for cg in provider.usedirect_campgrounds.values() - if cg.coordinates is not None - and haversine_distance_miles( + 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] ) - <= self.radius_miles - ] + 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( @@ -205,13 +213,23 @@ def _build_yellowstone_search( 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: - results.extend(sub_search.get_all_campsites()) + 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: From 4d5cd1288d5c03943e28a7030e4df1cfc743f261 Mon Sep 17 00:00:00 2001 From: winfij <23282949+winfij@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:41:03 -0700 Subject: [PATCH 15/15] fix: handle SMTP auth errors gracefully instead of crashing EmailNotifications.__init__ now catches SMTPException and raises CamplyError with a user-friendly message. CamplyError is now caught at the CLI top level and logged cleanly (no traceback) before exiting with code 1. Co-Authored-By: Claude Sonnet 4.6 --- camply/cli.py | 4 +++ camply/notifications/email_notifications.py | 29 +++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/camply/cli.py b/camply/cli.py index fb054294..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 @@ -1043,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/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: """