|
| 1 | +import logging |
| 2 | +import re |
1 | 3 | from typing import Optional |
2 | | -from pydantic import BaseModel |
| 4 | +from pydantic import BaseModel, Field, field_validator |
| 5 | + |
| 6 | +logger = logging.getLogger(__name__) |
| 7 | + |
| 8 | +URL_PATTERN = re.compile(r'^https?://') |
| 9 | + |
| 10 | +# (lo, hi) bounds for fields where an out-of-range value most likely means a scraping |
| 11 | +# glitch (e.g. an extra digit) rather than a real resort. Ceilings are set well above the |
| 12 | +# largest real value currently in data/resorts.csv, not at some theoretical world-record max. |
| 13 | +_NONNEG_BOUNDS: dict[str, tuple[float, Optional[float]]] = { |
| 14 | + 'acres': (0, 50_000), |
| 15 | + 'num_trails': (0, 1_000), |
| 16 | + 'num_trails_xc': (0, 500), |
| 17 | + 'num_lifts': (0, 500), |
| 18 | + 'vertical': (0, 15_000), |
| 19 | + 'vertical_meters': (0, 5_000), |
| 20 | + 'vertical_base_ft': (0, 20_000), |
| 21 | + 'vertical_summit_ft': (0, 25_000), |
| 22 | + 'vertical_elevation_ft': (0, 15_000), |
| 23 | + 'snowfall_average_in': (0, 2_000), |
| 24 | + 'snowfall_high_in': (0, 2_500), |
| 25 | + 'trail_length_mi': (0, 200), |
| 26 | + 'trail_length_km': (0, 320), |
| 27 | +} |
| 28 | + |
| 29 | +_DIFFICULTY_FIELDS = ( |
| 30 | + 'difficulty_beginner', |
| 31 | + 'difficulty_intermediate', |
| 32 | + 'difficulty_advanced', |
| 33 | + 'difficulty_beginner_xc', |
| 34 | + 'difficulty_intermediate_xc', |
| 35 | + 'difficulty_advanced_xc', |
| 36 | +) |
| 37 | + |
| 38 | +# Peak Rankings has, at least once, scored a resort an 11 (Spinal Tap style) — the ceiling |
| 39 | +# accommodates that rather than treating it as a data error. |
| 40 | +_PR_SUBSCORE_FIELDS = ( |
| 41 | + 'pr_snow', |
| 42 | + 'pr_resiliency', |
| 43 | + 'pr_size', |
| 44 | + 'pr_terrain_diversity', |
| 45 | + 'pr_challenge', |
| 46 | + 'pr_lifts', |
| 47 | + 'pr_crowd_flow', |
| 48 | + 'pr_facilities', |
| 49 | + 'pr_navigation', |
| 50 | + 'pr_mountain_aesthetic', |
| 51 | +) |
| 52 | + |
| 53 | + |
| 54 | +def _null_if_out_of_range(value, lo, hi, field_name, resort_id): |
| 55 | + if value is None: |
| 56 | + return None |
| 57 | + if (lo is not None and value < lo) or (hi is not None and value > hi): |
| 58 | + logger.warning( |
| 59 | + 'resort=%s field=%s value=%r out of range [%s, %s] — nulling', |
| 60 | + resort_id, |
| 61 | + field_name, |
| 62 | + value, |
| 63 | + lo, |
| 64 | + hi, |
| 65 | + ) |
| 66 | + return None |
| 67 | + return value |
| 68 | + |
| 69 | + |
| 70 | +def _null_if_bad_url(value, field_name, resort_id): |
| 71 | + if value is None: |
| 72 | + return None |
| 73 | + if not URL_PATTERN.match(value): |
| 74 | + logger.warning( |
| 75 | + 'resort=%s field=%s value=%r is not a valid http(s) URL — nulling', |
| 76 | + resort_id, |
| 77 | + field_name, |
| 78 | + value, |
| 79 | + ) |
| 80 | + return None |
| 81 | + return value |
3 | 82 |
|
4 | 83 |
|
5 | 84 | class RangeField(BaseModel): |
@@ -113,7 +192,7 @@ class Resort(BaseModel): |
113 | 192 | city: Optional[str] = None |
114 | 193 | state: Optional[str] = None |
115 | 194 | country: Optional[str] = None |
116 | | - indy_page: str |
| 195 | + indy_page: str = Field(pattern=URL_PATTERN) |
117 | 196 | website: Optional[str] = None |
118 | 197 | reservation_status: str |
119 | 198 | reservation_url: Optional[str] = None |
@@ -185,3 +264,34 @@ class Resort(BaseModel): |
185 | 264 | pr_nearest_cities: Optional[str] = None |
186 | 265 | pr_pass_affiliation: Optional[str] = None |
187 | 266 | pr_total_tt: Optional[str] = None |
| 267 | + |
| 268 | + @field_validator(*_NONNEG_BOUNDS.keys(), mode='after') |
| 269 | + @classmethod |
| 270 | + def _validate_nonneg_bounded(cls, v, info): |
| 271 | + lo, hi = _NONNEG_BOUNDS[info.field_name] |
| 272 | + return _null_if_out_of_range(v, lo, hi, info.field_name, info.data.get('resort_id')) |
| 273 | + |
| 274 | + @field_validator(*_DIFFICULTY_FIELDS, mode='after') |
| 275 | + @classmethod |
| 276 | + def _validate_difficulty(cls, v, info): |
| 277 | + return _null_if_out_of_range(v, 0, 100, info.field_name, info.data.get('resort_id')) |
| 278 | + |
| 279 | + @field_validator(*_PR_SUBSCORE_FIELDS, mode='after') |
| 280 | + @classmethod |
| 281 | + def _validate_pr_subscore(cls, v, info): |
| 282 | + return _null_if_out_of_range(v, 0, 11, info.field_name, info.data.get('resort_id')) |
| 283 | + |
| 284 | + @field_validator('pr_total', mode='after') |
| 285 | + @classmethod |
| 286 | + def _validate_pr_total(cls, v, info): |
| 287 | + return _null_if_out_of_range(v, 0, None, info.field_name, info.data.get('resort_id')) |
| 288 | + |
| 289 | + @field_validator('pr_overall_rank', 'pr_regional_rank', mode='after') |
| 290 | + @classmethod |
| 291 | + def _validate_pr_rank(cls, v, info): |
| 292 | + return _null_if_out_of_range(v, 1, None, info.field_name, info.data.get('resort_id')) |
| 293 | + |
| 294 | + @field_validator('website', 'reservation_url', mode='after') |
| 295 | + @classmethod |
| 296 | + def _validate_optional_url(cls, v, info): |
| 297 | + return _null_if_bad_url(v, info.field_name, info.data.get('resort_id')) |
0 commit comments