lib/Weather/Meteo.pm

Structural Coverage (Approximate)

TER1 (Statement): 96.36%
TER2 (Branch): 74.24%
TER3 (LCSAJ): 100.0% (30/30)
Approximate LCSAJ segments: 133

LCSAJ Legend

โ— Covered โ€” this LCSAJ path was executed during testing.

โ— Not covered โ€” this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start โ†’ end โ†’ jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package Weather::Meteo;
    2: 
    3: use strict;
    4: use warnings;
    5: 
    6: use Carp;
    7: use CHI;
    8: use JSON::MaybeXS;
    9: use LWP::UserAgent;
   10: use Object::Configure;
   11: use Params::Get 0.13;
   12: use Params::Validate::Strict;
   13: use Return::Set;
   14: use Scalar::Util;
   15: use Time::HiRes;
   16: use URI;
   17: 
   18: # Archive API host (historical data from 1940 onwards)
   19: use constant DEFAULT_HOST  => 'archive-api.open-meteo.com';
   20: # Forecast API host (up to 16 days ahead)
   21: use constant FORECAST_HOST => 'api.open-meteo.com';
   22: use constant FIRST_YEAR    => 1940;
   23: use constant EXPIRES_IN    => '1 hour';
   24: use constant MIN_INTERVAL  => 0;
   25: # Default timezone when neither the caller nor the location object supplies one
   26: use constant DEFAULT_TZ    => 'Europe/London';
   27: 
   28: =head1 NAME
   29: 
   30: Weather::Meteo - Interface to L<https://open-meteo.com> for historical and forecast weather data
   31: 
   32: =head1 VERSION
   33: 
   34: Version 0.15
   35: 
   36: =cut
   37: 
   38: our $VERSION = '0.15';
   39: 
   40: =head1 SYNOPSIS
   41: 
   42: The C<Weather::Meteo> module provides an interface to the Open-Meteo API for retrieving
   43: historical weather data from 1940 and weather forecasts up to 16 days ahead.
   44: It allows users to fetch weather information by specifying latitude, longitude, and a date.
   45: The module supports object-oriented usage and allows customisation of the HTTP user agent.
   46: 
   47:       use Weather::Meteo;
   48: 
   49:       my $meteo = Weather::Meteo->new();
   50: 
   51:       # Historical weather
   52:       my $weather = $meteo->weather({ latitude => 0.1, longitude => 0.2, date => '2022-12-25' });
   53: 
   54:       # Forecast (default 7 days)
   55:       my $forecast = $meteo->forecast({ latitude => 51.34, longitude => 1.42 });
   56: 
   57:       # Sunrise and sunset for a specific date
   58:       my $times = $meteo->sunrise_sunset({ latitude => 51.34, longitude => 1.42, date => '2025-06-21' });
   59:       print "Sunrise: $times->{sunrise}\n";
   60: 
   61: =over 4
   62: 
   63: =item * Caching
   64: 
   65: Identical requests are cached (using L<CHI> or a user-supplied caching object),
   66: reducing the number of HTTP requests to the API and speeding up repeated queries.
   67: 
   68: When a request is made,
   69: a cache key is constructed from the coordinates, date, and timezone.
   70: If a cached response exists it is returned immediately,
   71: avoiding unnecessary API calls.
   72: 
   73: =item * Rate-Limiting
   74: 
   75: A minimum interval between successive API calls can be enforced to ensure that the
   76: API is not overwhelmed and to comply with any request throttling requirements.
   77: 
   78: Rate-limiting is implemented using L<Time::HiRes>.
   79: A minimum interval between API calls can be specified via the C<min_interval> parameter
   80: in the constructor.
   81: Before making an API call,
   82: the module checks how much time has elapsed since the last request and,
   83: if necessary,
   84: sleeps for the remaining time.
   85: 
   86: =back
   87: 
   88: =head1 METHODS
   89: 
   90: =head2 new
   91: 
   92:     my $meteo = Weather::Meteo->new();
   93: 
   94:     # Custom user agent with proxy support
   95:     my $ua = LWP::UserAgent->new();
   96:     $ua->env_proxy(1);
   97:     $meteo = Weather::Meteo->new(ua => $ua);
   98: 
   99:     # Clone an existing object and override one slot
  100:     my $clone = $meteo->new(host => 'custom.example.com');
  101: 
  102: Creates a new C<Weather::Meteo> instance.
  103: When called on an existing C<Weather::Meteo> object,
  104: clones that object and merges the supplied parameters.
  105: 
  106: =over 4
  107: 
  108: =item * C<cache>
  109: 
  110: A caching object.
  111: If not provided,
  112: an in-memory cache is created with a default expiration of one hour.
  113: 
  114: =item * C<host>
  115: 
  116: The archive API host endpoint.
  117: Defaults to C<archive-api.open-meteo.com>.
  118: 
  119: Must be a plain DNS hostname - letters, digits, hyphens, and dots - with an
  120: optional port suffix (e.g. C<mock.example.com:8080>).
  121: Values containing C<@>, path segments, or other special characters are rejected
  122: with a C<croak> to prevent Server-Side Request Forgery (SSRF) via the
  123: C<WEATHER__METEO__host> environment variable or configuration file.
  124: Falsy values (C<undef>, C<"">, C<0>) fall back to the default silently.
  125: 
  126: =item * C<logger>
  127: 
  128: An optional logger object.
  129: Must respond to C<error()>.
  130: When supplied, API errors are reported through this logger in addition to C<Carp::carp>.
  131: 
  132: =item * C<min_interval>
  133: 
  134: Minimum number of seconds to wait between API requests.
  135: Defaults to C<0> (no delay).
  136: Use this option to enforce rate-limiting.
  137: 
  138: =item * C<ua>
  139: 
  140: An object to use for HTTP requests.
  141: If not provided, a default C<LWP::UserAgent> is created.
  142: Must respond to C<get()>.
  143: 
  144: =back
  145: 
  146: The class can be configured at runtime using environment variables and configuration files,
  147: for example,
  148: setting C<$ENV{'WEATHER__METEO__carp_on_warn'}> causes warnings to use L<Carp>.
  149: For more information about runtime configuration,
  150: see L<Object::Configure>.
  151: 
  152: =head3 EXAMPLE
  153: 
  154:     # Minimal -- use all defaults
  155:     my $meteo = Weather::Meteo->new();
  156: 
  157:     # Custom UA with throttling
  158:     use LWP::UserAgent::Throttled;
  159:     my $ua = LWP::UserAgent::Throttled->new();
  160:     $ua->throttle('open-meteo.com' => 1);
  161:     my $meteo = Weather::Meteo->new(ua => $ua, min_interval => 1);
  162: 
  163:     # Clone the object but change the host for integration testing
  164:     my $test_meteo = $meteo->new(host => 'mock.example.com');
  165: 
  166: =head3 API SPECIFICATION
  167: 
  168: =head4 Input
  169: 
  170: All parameters are optional.
  171: They may be supplied as a hashref or a flat key/value list.
  172: When C<$class> is an existing C<Weather::Meteo> object the call clones it,
  173: merging any supplied parameters.
  174: 
  175:     {
  176:         ua           => { type => 'object', can => 'get',   optional => 1 },
  177:         cache        => { type => 'object',                  optional => 1 },
  178:         host         => { type => 'scalar',                  optional => 1 },
  179:         min_interval => { type => 'scalar',                  optional => 1 },
  180:         logger       => { type => 'object', can => 'error', optional => 1 },
  181:     }
  182: 
  183: =head4 Output
  184: 
  185:     { type => 'object', isa => 'Weather::Meteo' }
  186: 
  187: =head3 MESSAGES
  188: 
  189:     Message                                            Type   Trigger
  190:     -------------------------------------------------  -----  -----------------------------------
  191:     'ua' argument must be an object with a get()       croak  clone called with an invalid ua arg
  192:     method
  193:     Invalid host '$host': must be a plain hostname     croak  host contains @, /, or other chars
  194:                                                               that are not safe in a DNS label
  195: 
  196: =cut
  197: 
  198: sub new {
โ—199 โ†’ 202 โ†’ 218  199: 	my $class = shift;
  200: 	my $params = Params::Get::get_params(undef, \@_) || {};
  201: 
  202: 	if(!defined($class)) {
  203: 		# Weather::Meteo::new() used rather than Weather::Meteo->new()
  204: 		$class = __PACKAGE__;
  205: 	} elsif(Scalar::Util::blessed($class)) {
  206: 		# Clone path: merge new params over the existing object's fields.
  207: 		if(exists($params->{ua})) {
  208: 			if(!defined($params->{ua})) {
  209: 				# ua=>undef means "keep the original" -- silently drop it
  210: 				delete $params->{ua};
  211: 			} elsif(!Scalar::Util::blessed($params->{ua}) || !$params->{ua}->can('get')) {
  212: 				Carp::croak("'ua' argument must be an object with a get() method");
  213: 			}
  214: 		}
  215: 		return bless { %{$class}, %{$params} }, ref($class);
  216: 	}
  217: 
โ—218 โ†’ 224 โ†’ 230  218: 	$params = Object::Configure::configure($class, $params);
  219: 
  220: 	# Validate and untaint host: only DNS labels + optional port are accepted.
  221: 	# Prevents SSRF via WEATHER__METEO__host env var or config file injection.
  222: 	# Falsy values (undef, "", 0) are left as-is -- they fall back to DEFAULT_HOST
  223: 	# in the bless statement and never reach the URL constructor.
  224: 	if($params->{host}) {
  225: 		(my $safe_host) = ($params->{host} =~ /\A([A-Za-z0-9][A-Za-z0-9.\-]*(:\d{1,5})?)\z/)
  226: 			or Carp::croak("Invalid host '$params->{host}': must be a plain hostname");
  227: 		$params->{host} = $safe_host;
  228: 	}
  229: 
โ—230 โ†’ 231 โ†’ 236  230: 	my $ua = $params->{ua};
  231: 	if(!defined($ua)) {
  232: 		$ua = LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");

Mutants (Total: 1, Killed: 1, Survived: 0)

233: $ua->default_header(accept_encoding => 'gzip,deflate'); 234: } 235: 236: my $cache = $params->{cache} || CHI->new( 237: driver => 'Memory',

Mutants (Total: 1, Killed: 0, Survived: 1)
238: global => 1,

Mutants (Total: 1, Killed: 1, Survived: 0)

239: expires_in => EXPIRES_IN, 240: ); 241: 242: return bless { 243: min_interval => $params->{min_interval} || MIN_INTERVAL, 244: last_request => 0, 245: %{$params},

Mutants (Total: 2, Killed: 2, Survived: 0)

246: cache => $cache, 247: host => $params->{host} || DEFAULT_HOST, 248: ua => $ua, 249: }, $class; 250: } 251: 252: =head2 weather 253: 254: use Geo::Location::Point;

Mutants (Total: 1, Killed: 1, Survived: 0)

255: 256: my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 }); 257: my $weather = $meteo->weather($ramsgate, '2022-12-25'); 258: 259: # Print snowfall at 1AM on Christmas morning in Ramsgate 260: my @snowfall = @{$weather->{'hourly'}->{'snowfall'}}; 261: print 'Snowfall at 1AM: ', $snowfall[1], " cm\n";

Mutants (Total: 1, Killed: 1, Survived: 0)

262: 263: use DateTime; 264: my $dt = DateTime->new(year => 2024, month => 2, day => 1); 265: $weather = $meteo->weather({ location => $ramsgate, date => $dt }); 266: 267: The date argument can be an ISO-8601 formatted string (C<YYYY-MM-DD>), 268: or any object that supports C<strftime>. 269: 270: Takes an optional C<tz> argument containing the time zone. 271: If not given, the module tries to derive it from the location object; 272: set C<TIMEZONEDB_KEY> to your API key from L<https://timezonedb.com> to enable that.

Mutants (Total: 2, Killed: 2, Survived: 0)

273: If all else fails, the module falls back to C<Europe/London>. 274: 275: Dates before 1940 return C<undef> silently. 276: Invalid date strings cause a C<carp> and return C<undef>. 277: Missing required arguments or non-numeric coordinates cause a C<croak>. 278: 279: On success returns a hashref with at minimum an C<hourly> key. 280: The C<daily> key includes C<sunrise> and C<sunset> as ISO-8601 datetime strings 281: (e.g. C<2022-12-25T08:09>), as well as temperature, precipitation, and wind fields. 282: Returns C<undef> if the API returns an error, if the JSON cannot be 283: parsed, or if the response contains no C<hourly> key. 284: 285: =head3 EXAMPLE 286: 287: my $meteo = Weather::Meteo->new(); 288: my $weather = $meteo->weather({ latitude => 51.34, longitude => 1.42, date => '2022-12-25' }); 289: 290: if(defined($weather)) { 291: my $max_temp = $weather->{'daily'}->{'temperature_2m_max'}[0]; 292: my $sunrise = $weather->{'daily'}->{'sunrise'}[0]; 293: my @temps = @{$weather->{'hourly'}->{'temperature_2m'}}; 294: print "Max temp: ${max_temp}C Sunrise: $sunrise\n"; 295: print "Temp at noon: $temps[12]C\n"; 296: } 297: 298: =head3 API SPECIFICATION 299: 300: =head4 Input 301: 302: Three call forms are accepted. 303: 304: # Form 1 and 2 -- hashref or flat list 305: { 306: latitude => { type => 'scalar' }, 307: longitude => { type => 'scalar' }, 308: date => { type => 'scalar | object' }, 309: tz => { type => 'scalar', optional => 1 }, 310: location => { type => 'object', can => 'latitude', optional => 1 }, 311: } 312: 313: # Form 3 -- positional: ($location_obj, $date) 314: # $location_obj must respond to latitude() and longitude() 315: 316: =head4 Output 317: 318: { type => 'hashref', min => 1 } # success -- contains 'hourly' key 319: undef # pre-1940 date, bad input, or API error 320: 321: =head3 MESSAGES 322: 323: Message Type Trigger 324: --------------------------------------------------- ----- ---------------------------------- 325: Usage: weather(latitude => ...) croak lat, lon, or date is missing 326: Invalid latitude/longitude format ($lat, $lon) croak coordinate is not numeric 327: '$date' is not a valid date carp date string is not YYYY-MM-DD 328: Invalid date format. Expected YYYY-MM-DD croak strftime() returned wrong format 329: UA->get did not return a valid HTTP response carp UA returned non-response object 330: $url API returned error: $status carp HTTP 4xx/5xx response 331: Failed to parse JSON response: $err carp response body is not valid JSON 332: ($err is the exception with control 333: chars stripped and length capped at 334: 200 chars to prevent log injection) 335: Weather::Meteo: API error: $reason carp API returned {"error":true,...} 336: 337: =head3 PSEUDOCODE 338: 339: parse call form (3 variants: hashref, flat list, positional (location, date)) 340: extract lat, lon, date, tz; resolve location object if given 341: croak if lat, lon, or date is missing 342: normalise leading-decimal coordinates via _normalise_coord() 343: validate coordinates with /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ 344: (atomic groups prevent ReDoS; list-context capture also untaints for perl -T) 345: croak if either coordinate does not match 346: if date is a strftime object: call strftime('%F'); croak if result not YYYY-MM-DD 347: return undef silently if year < 1940 348: carp and return undef if date string is not YYYY-MM-DD 349: return cached result if available 350: build URL for /v1/archive endpoint with hourly and daily fields 351: fetch and decode JSON via _fetch_json() 352: return undef if HTTP error, JSON error, or API-level error (with carp) 353: return undef if response has no 'hourly' key 354: store result in cache 355: return hashref (enforced by Return::Set) 356: 357: =cut 358: 359: sub weather 360: { โ—361 โ†’ 364 โ†’ 378 361: my $self = shift; 362: my $params; 363: 364: if((scalar(@_) == 2) && Scalar::Util::blessed($_[0]) && ($_[0]->can('latitude'))) { 365: # Two-arg positional form: (location_obj, date) 366: my $location = $_[0]; 367: $params = { 368: latitude => $location->latitude(), 369: longitude => $location->longitude(), 370: date => $_[1], 371: }; 372: $params->{'tz'} = $_[0]->tz() 373: if $_[0]->can('tz') && $ENV{'TIMEZONEDB_KEY'}; 374: } else { 375: $params = Params::Get::get_params(undef, \@_); 376: } 377: โ—378 โ†’ 384 โ†’ 390 378: my $latitude = $params->{latitude}; 379: my $longitude = $params->{longitude}; 380: my $location = $params->{'location'}; 381: my $date = $params->{'date'}; 382: my $tz = $params->{'tz'} || DEFAULT_TZ; 383: 384: if(!defined($latitude) && defined($location) && 385: Scalar::Util::blessed($location) && $location->can('latitude')) { 386: $latitude = $location->latitude(); 387: $longitude = $location->longitude(); 388: } 389: โ—390 โ†’ 390 โ†’ 396 390: if(!defined($latitude) || !defined($longitude) || !defined($date)) { 391: my $msg = 'Usage: weather(latitude => $latitude, longitude => $longitude, date => "YYYY-MM-DD")'; 392: $self->{'logger'}->error($msg) if $self->{'logger'}; 393: Carp::croak($msg); 394: } 395: โ—396 โ†’ 403 โ†’ 408 396: $latitude = _normalise_coord($latitude); 397: $longitude = _normalise_coord($longitude); 398: 399: # Atomic groups prevent O(n) backtracking on adversarial input; list-context 400: # capture also untaints the values for taint-mode compliance. 401: my ($lat_clean) = ($latitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 402: my ($lon_clean) = ($longitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 403: if(!defined($lat_clean) || !defined($lon_clean)) { 404: my $msg = __PACKAGE__ . ": Invalid latitude/longitude format ($latitude, $longitude)"; 405: $self->{'logger'}->error($msg) if $self->{'logger'}; 406: Carp::croak($msg); 407: } โ—408 โ†’ 411 โ†’ 420 408: $latitude = $lat_clean; 409: $longitude = $lon_clean; 410: 411: if(Scalar::Util::blessed($date) && $date->can('strftime')) { 412: $date = $date->strftime('%F'); 413: } elsif($date =~ /^(\d{4})-/) { 414: return if $1 < FIRST_YEAR; 415: } else { 416: Carp::carp("'$date' is not a valid date"); 417: return; 418: } 419: โ—420 โ†’ 420 โ†’ 426 420: unless($date =~ /^\d{4}-\d{2}-\d{2}$/) { 421: my $msg = 'Invalid date format. Expected YYYY-MM-DD'; 422: $self->{'logger'}->error($msg) if $self->{'logger'}; 423: Carp::croak($msg); 424: } 425: โ—426 โ†’ 427 โ†’ 431 426: my $cache_key = "weather:$latitude:$longitude:$date:$tz"; 427: if(my $cached = $self->{'cache'}->get($cache_key)) { 428: return $cached; 429: } 430: โ—431 โ†’ 450 โ†’ 460 431: my $uri = URI->new("https://$self->{host}/v1/archive"); 432: $uri->query_form( 433: latitude => $latitude, 434: longitude => $longitude, 435: start_date => $date, 436: end_date => $date, 437: hourly => 'temperature_2m,rain,snowfall,weathercode', 438: daily => 'weathercode,temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_hours,windspeed_10m_max,windgusts_10m_max,sunrise,sunset', 439: timezone => $tz, 440: # https://stackoverflow.com/questions/16086962/how-to-get-a-time-zone-from-a-location-using-latitude-and-longitude-coordinates 441: windspeed_unit => 'mph', 442: precipitation_unit => 'inch', 443: ); 444: my $url = $uri->as_string();

Mutants (Total: 2, Killed: 2, Survived: 0)

445: $url =~ s/%2C/,/g; 446: 447: my $rc = $self->_fetch_json($url); 448: return unless defined($rc) && ref($rc) eq 'HASH'; 449: 450: if($rc->{'error'}) { 451: # Surface the API-provided reason so callers can diagnose failures 452: my $reason = $rc->{'reason'} // 'unknown'; 453: # eval guard: logger->error() may be fatal (e.g. Log::Abstraction), but 454: # the documented contract is to return undef, so we must not propagate the die 455: eval { $self->{'logger'}->error(__PACKAGE__ . ": API error: $reason") } if $self->{'logger'}; 456: Carp::carp(__PACKAGE__ . ": API error: $reason"); 457: return; 458: } 459: 460: return unless defined($rc->{'hourly'}); 461: 462: $self->{'cache'}->set($cache_key, $rc); 463: return Return::Set::set_return($rc, { type => 'hashref', min => 1 }); 464: }

Mutants (Total: 1, Killed: 1, Survived: 0)

465: 466: =head2 forecast 467: 468: my $meteo = Weather::Meteo->new(); 469: my $forecast = $meteo->forecast({ latitude => 51.34, longitude => 1.42 }); 470: my @temps = @{$forecast->{'hourly'}->{'temperature_2m'}};

Mutants (Total: 1, Killed: 1, Survived: 0)

471: 472: # Request 3 days of forecast 473: $forecast = $meteo->forecast({ latitude => 51.34, longitude => 1.42, days => 3 }); 474: 475: use Geo::Location::Point; 476: my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 }); 477: $forecast = $meteo->forecast($ramsgate); 478: $forecast = $meteo->forecast($ramsgate, 5); 479: 480: Fetches weather forecast data from L<https://api.open-meteo.com>. 481: Returns up to 16 days of hourly and daily data. 482: The C<daily> key of the response includes C<sunrise> and C<sunset> ISO-8601 datetime strings. 483:

Mutants (Total: 1, Killed: 1, Survived: 0)

484: Takes an optional C<days> argument (integer 1-16, default 7). 485: Takes an optional C<tz> argument for the time zone; defaults to C<Europe/London>. 486: 487: On success returns a hashref containing at minimum the key C<hourly>. 488: Returns C<undef> if the API returns an error, if the JSON cannot be parsed, 489: or if the response contains no C<hourly> key. 490: 491: =head3 EXAMPLE

Mutants (Total: 1, Killed: 1, Survived: 0)

492: 493: my $meteo = Weather::Meteo->new(); 494: my $forecast = $meteo->forecast({ latitude => 51.34, longitude => 1.42, days => 5 });

Mutants (Total: 3, Killed: 3, Survived: 0)

495: 496: if(defined($forecast)) { 497: my $daily = $forecast->{'daily'}; 498: my @sunrises = @{$daily->{'sunrise'}}; 499: my @max_temps = @{$daily->{'temperature_2m_max'}}; 500: for my $i (0 .. $#sunrises) {

Mutants (Total: 1, Killed: 1, Survived: 0)

501: print "Day $i: sunrise $sunrises[$i], max $max_temps[$i]C\n"; 502: } 503: } 504: 505: =head3 API SPECIFICATION 506: 507: =head4 Input

Mutants (Total: 1, Killed: 1, Survived: 0)

508:

Mutants (Total: 2, Killed: 2, Survived: 0)

509: Three call forms are accepted. 510: 511: # Form 1 and 2 -- hashref or flat list 512: { 513: latitude => { type => 'scalar' }, 514: longitude => { type => 'scalar' }, 515: days => { type => 'scalar', optional => 1 }, 516: tz => { type => 'scalar', optional => 1 }, 517: location => { type => 'object', can => 'latitude', optional => 1 }, 518: } 519: 520: # Form 3 -- positional: ($location_obj) or ($location_obj, $days) 521: # $location_obj must respond to latitude() and longitude() 522: 523: =head4 Output 524: 525: { type => 'hashref', min => 1 } # success -- contains 'hourly' key 526: undef # bad input or API error 527: 528: =head3 MESSAGES 529: 530: Message Type Trigger

Mutants (Total: 1, Killed: 1, Survived: 0)

531: --------------------------------------------------- ----- ---------------------------------- 532: Usage: forecast(latitude => ...) croak lat or lon is missing 533: Invalid latitude/longitude format ($lat, $lon) croak coordinate is not numeric 534: days must be between 1 and 16; defaulting to 7 carp days argument is out of range 535: UA->get did not return a valid HTTP response carp UA returned non-response object 536: $url API returned error: $status carp HTTP 4xx/5xx response 537: Failed to parse JSON response: $err carp response body is not valid JSON 538: ($err is the exception with control 539: chars stripped and length capped at 540: 200 chars to prevent log injection) 541: 542: =head3 PSEUDOCODE 543:

Mutants (Total: 2, Killed: 2, Survived: 0)

544: parse call form (3 variants: hashref, flat list, positional (location) or (location, days)) 545: extract lat, lon, days, tz; resolve location object if given 546: croak if lat or lon is missing 547: normalise leading-decimal coordinates via _normalise_coord() 548: validate coordinates with /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ 549: (atomic groups prevent ReDoS; list-context capture also untaints for perl -T) 550: croak if either coordinate does not match 551: clamp days to 1-16: carp and default to 7 if out of range 552: return cached result if available 553: build URL for FORECAST_HOST/v1/forecast with forecast_days parameter 554: fetch and decode JSON via _fetch_json() 555: return undef on error or if response has no 'hourly' key 556: store result in cache 557: return hashref (enforced by Return::Set) 558: 559: =cut 560: 561: sub forecast 562: { โ—563 โ†’ 566 โ†’ 580 563: my $self = shift; 564: my $params; 565: 566: if(scalar(@_) >= 1 && Scalar::Util::blessed($_[0]) && $_[0]->can('latitude')) { 567: # Positional form: (location_obj) or (location_obj, days) 568: my $location = $_[0]; 569: $params = { 570: latitude => $location->latitude(), 571: longitude => $location->longitude(), 572: }; 573: $params->{days} = $_[1] if defined($_[1]); 574: $params->{tz} = $location->tz() 575: if $location->can('tz') && $ENV{'TIMEZONEDB_KEY'}; 576: } else { 577: $params = Params::Get::get_params(undef, \@_); 578: } 579: โ—580 โ†’ 586 โ†’ 592 580: my $latitude = $params->{latitude}; 581: my $longitude = $params->{longitude}; 582: my $location = $params->{location}; 583: my $days = $params->{days} // 7; 584: my $tz = $params->{tz} || DEFAULT_TZ; 585: 586: if(!defined($latitude) && defined($location) && 587: Scalar::Util::blessed($location) && $location->can('latitude')) { 588: $latitude = $location->latitude(); 589: $longitude = $location->longitude(); 590: } 591: โ—592 โ†’ 592 โ†’ 598 592: if(!defined($latitude) || !defined($longitude)) { 593: my $msg = 'Usage: forecast(latitude => $latitude, longitude => $longitude)'; 594: $self->{'logger'}->error($msg) if $self->{'logger'}; 595: Carp::croak($msg); 596: } 597: โ—598 โ†’ 603 โ†’ 608 598: $latitude = _normalise_coord($latitude); 599: $longitude = _normalise_coord($longitude); 600: 601: my ($lat_clean) = ($latitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 602: my ($lon_clean) = ($longitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 603: if(!defined($lat_clean) || !defined($lon_clean)) { 604: my $msg = __PACKAGE__ . ": Invalid latitude/longitude format ($latitude, $longitude)"; 605: $self->{'logger'}->error($msg) if $self->{'logger'}; 606: Carp::croak($msg); 607: } โ—608 โ†’ 611 โ†’ 616 608: $latitude = $lat_clean; 609: $longitude = $lon_clean; 610: 611: if($days !~ /^\d+$/ || $days < 1 || $days > 16) { 612: Carp::carp('days must be between 1 and 16; defaulting to 7'); 613: $days = 7; 614: } 615: โ—616 โ†’ 617 โ†’ 621 616: my $cache_key = "forecast:$latitude:$longitude:$days:$tz"; 617: if(my $cached = $self->{'cache'}->get($cache_key)) { 618: return $cached; 619: } 620: 621: my $uri = URI->new('https://' . FORECAST_HOST . '/v1/forecast'); 622: $uri->query_form( 623: latitude => $latitude, 624: longitude => $longitude, 625: forecast_days => $days, 626: hourly => 'temperature_2m,rain,snowfall,weathercode', 627: daily => 'weathercode,temperature_2m_max,temperature_2m_min,rain_sum,snowfall_sum,precipitation_hours,windspeed_10m_max,windgusts_10m_max,sunrise,sunset', 628: timezone => $tz, 629: windspeed_unit => 'mph', 630: precipitation_unit => 'inch', 631: ); 632: my $url = $uri->as_string(); 633: $url =~ s/%2C/,/g; 634: 635: my $rc = $self->_fetch_json($url); 636: return unless defined($rc) && ref($rc) eq 'HASH'; 637: return if $rc->{'error'}; 638: return unless defined($rc->{'hourly'}); 639: 640: $self->{'cache'}->set($cache_key, $rc); 641: return Return::Set::set_return($rc, { type => 'hashref', min => 1 }); 642: } 643: 644: =head2 sunrise_sunset 645: 646: my $meteo = Weather::Meteo->new(); 647: 648: # Historical date -- uses the archive endpoint 649: my $times = $meteo->sunrise_sunset({ latitude => 51.34, longitude => 1.42, date => '2022-12-25' }); 650: print "Sunrise: $times->{sunrise}\n"; 651: print "Sunset: $times->{sunset}\n"; 652: 653: # Today (no date given -- uses the forecast endpoint) 654: $times = $meteo->sunrise_sunset({ latitude => 51.34, longitude => 1.42 }); 655: 656: use Geo::Location::Point; 657: my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 }); 658: $times = $meteo->sunrise_sunset($ramsgate, '2022-12-25'); 659: 660: Returns a hashref with C<sunrise> and C<sunset> ISO-8601 datetime strings 661: (e.g. C<2022-12-25T08:09>) for the given location and date. 662: 663: If no date is supplied, today is used and the forecast endpoint is queried. 664: For historical dates (strictly before today) the archive endpoint is used. 665: For today and future dates the forecast endpoint (L<https://api.open-meteo.com>) is used. 666: 667: Takes an optional C<tz> argument for the time zone; defaults to C<Europe/London>. 668: 669: Returns C<undef> if the API returns an error or if the response does not contain 670: sunrise/sunset data. 671: 672: =head3 EXAMPLE 673: 674: my $meteo = Weather::Meteo->new(); 675: my $times = $meteo->sunrise_sunset({ latitude => 48.8566, longitude => 2.3522 }); 676: 677: if(defined($times)) { 678: print "Paris sunrise today: $times->{sunrise}\n"; 679: print "Paris sunset today: $times->{sunset}\n"; 680: } 681: 682: # Historical query 683: my $solstice = $meteo->sunrise_sunset({ 684: latitude => 51.4779, 685: longitude => -0.0015, 686: date => '2024-06-21', 687: tz => 'Europe/London', 688: }); 689: print "Greenwich sunrise on summer solstice 2024: $solstice->{sunrise}\n"; 690: 691: =head3 API SPECIFICATION 692:

Mutants (Total: 4, Killed: 4, Survived: 0)

693: =head4 Input 694: 695: Three call forms are accepted. 696: 697: # Form 1 and 2 -- hashref or flat list 698: { 699: latitude => { type => 'scalar' }, 700: longitude => { type => 'scalar' }, 701: date => { type => ['scalar', 'object'], optional => 1 }, 702: tz => { type => 'scalar', optional => 1 }, 703: location => { type => 'object', can => 'latitude', optional => 1 }, 704: } 705: 706: # Form 3 -- positional: ($location_obj) or ($location_obj, $date) 707: # $location_obj must respond to latitude() and longitude() 708: 709: =head4 Output 710: 711: { type => 'hashref' } # { sunrise => STRING, sunset => STRING } 712: undef # bad input or API error

Mutants (Total: 1, Killed: 1, Survived: 0)

713: 714: =head3 MESSAGES 715: 716: Message Type Trigger 717: --------------------------------------------------- ----- ---------------------------------- 718: Usage: sunrise_sunset(latitude => ...) croak lat or lon is missing

Mutants (Total: 1, Killed: 1, Survived: 0)

719: Invalid latitude/longitude format ($lat, $lon) croak coordinate is not numeric 720: '$date' is not a valid date carp date string is not YYYY-MM-DD 721: UA->get did not return a valid HTTP response carp UA returned non-response object 722: $url API returned error: $status carp HTTP 4xx/5xx response 723: Failed to parse JSON response: $err carp response body is not valid JSON 724: ($err is the exception with control 725: chars stripped and length capped at 726: 200 chars to prevent log injection) 727: 728: =head3 PSEUDOCODE 729:

Mutants (Total: 1, Killed: 1, Survived: 0)

730: parse call form (3 variants: hashref, flat list, positional (location) or (location, date)) 731: extract lat, lon, date, tz; resolve location object if given 732: croak if lat or lon is missing 733: normalise leading-decimal coordinates via _normalise_coord() 734: validate coordinates with /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ 735: (atomic groups prevent ReDoS; list-context capture also untaints for perl -T) 736: croak if either coordinate does not match 737: if date is a strftime object: call strftime('%F')

Mutants (Total: 7, Killed: 7, Survived: 0)

738: carp and return undef if date string is not YYYY-MM-DD 739: determine endpoint: archive for historical dates, forecast for today/future 740: default date to today if omitted 741: return cached result if available 742: build URL with daily=sunrise,sunset only (no hourly fields) 743: fetch and decode JSON via _fetch_json()

Mutants (Total: 1, Killed: 1, Survived: 0)

744: return undef on error or if daily sunrise/sunset arrays are absent

Mutants (Total: 2, Killed: 0, Survived: 2)
745: extract sunrise[0] and sunset[0] 746: store { sunrise, sunset } in cache 747: return hashref 748: 749: =cut 750: 751: sub sunrise_sunset 752: { โ—753 โ†’ 756 โ†’ 770 753: my $self = shift; 754: my $params; 755: 756: if(scalar(@_) >= 1 && Scalar::Util::blessed($_[0]) && $_[0]->can('latitude')) { 757: # Positional form: (location_obj) or (location_obj, date) 758: my $location = $_[0]; 759: $params = { 760: latitude => $location->latitude(), 761: longitude => $location->longitude(), 762: }; 763: $params->{date} = $_[1] if defined($_[1]); 764: $params->{tz} = $location->tz() 765: if $location->can('tz') && $ENV{'TIMEZONEDB_KEY'}; 766: } else { 767: $params = Params::Get::get_params(undef, \@_);

Mutants (Total: 2, Killed: 2, Survived: 0)

768: } 769: โ—770 โ†’ 775 โ†’ 781 770: my $latitude = $params->{latitude}; 771: my $longitude = $params->{longitude}; 772: my $location = $params->{location}; 773: my $tz = $params->{'tz'} || DEFAULT_TZ; 774: 775: if(!defined($latitude) && defined($location) && 776: Scalar::Util::blessed($location) && $location->can('latitude')) { 777: $latitude = $location->latitude(); 778: $longitude = $location->longitude(); 779: } 780: โ—781 โ†’ 781 โ†’ 787 781: if(!defined($latitude) || !defined($longitude)) { 782: my $msg = 'Usage: sunrise_sunset(latitude => $latitude, longitude => $longitude)'; 783: $self->{'logger'}->error($msg) if $self->{'logger'}; 784: Carp::croak($msg); 785: } 786: โ—787 โ†’ 792 โ†’ 797 787: $latitude = _normalise_coord($latitude); 788: $longitude = _normalise_coord($longitude); 789: 790: my ($lat_clean) = ($latitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 791: my ($lon_clean) = ($longitude =~ /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/); 792: if(!defined($lat_clean) || !defined($lon_clean)) { 793: my $msg = __PACKAGE__ . ": Invalid latitude/longitude format ($latitude, $longitude)"; 794: $self->{'logger'}->error($msg) if $self->{'logger'}; 795: Carp::croak($msg); 796: } โ—797 โ†’ 804 โ†’ 808 797: $latitude = $lat_clean; 798: $longitude = $lon_clean; 799: 800: my @t = localtime(time()); 801: my $today = sprintf('%04d-%02d-%02d', $t[5] + 1900, $t[4] + 1, $t[3]); 802: my $date = $params->{date}; 803: 804: if(defined($date) && Scalar::Util::blessed($date) && $date->can('strftime')) { 805: $date = $date->strftime('%F'); 806: } 807: โ—808 โ†’ 808 โ†’ 814 808: if(defined($date) && $date !~ /^\d{4}-\d{2}-\d{2}$/) { 809: Carp::carp("'$date' is not a valid date"); 810: return; 811: } 812: 813: # No date means use forecast endpoint (more reliable for today than the archive) โ—814 โ†’ 818 โ†’ 822 814: my $use_forecast = !defined($date) || ($date ge $today); 815: $date //= $today; 816: 817: my $cache_key = "sunrise_sunset:$latitude:$longitude:$date:$tz"; 818: if(my $cached = $self->{'cache'}->get($cache_key)) { 819: return $cached; 820: } 821: 822: my $endpoint_host = $use_forecast ? FORECAST_HOST : $self->{host}; 823: my $endpoint_path = $use_forecast ? '/v1/forecast' : '/v1/archive'; 824: 825: my $uri = URI->new("https://$endpoint_host$endpoint_path"); 826: $uri->query_form( 827: latitude => $latitude, 828: longitude => $longitude, 829: start_date => $date, 830: end_date => $date, 831: daily => 'sunrise,sunset', 832: timezone => $tz, 833: ); 834: 835: my $rc = $self->_fetch_json($uri->as_string()); 836: return unless defined($rc) && ref($rc) eq 'HASH' && !$rc->{'error'}; 837: 838: my $daily = $rc->{'daily'}; 839: return unless ref($daily) eq 'HASH'; 840: 841: my $sr = ref($daily->{'sunrise'}) eq 'ARRAY' ? $daily->{'sunrise'}[0] : undef; 842: my $ss = ref($daily->{'sunset'}) eq 'ARRAY' ? $daily->{'sunset'}[0] : undef; 843: return unless defined($sr) && defined($ss); 844: 845: my $result = { sunrise => $sr, sunset => $ss }; 846: $self->{'cache'}->set($cache_key, $result); 847: return $result; 848: } 849: 850: =head2 ua 851: 852: Accessor method to get and set the C<UserAgent> object used internally. 853: You can call C<env_proxy> for example, to get proxy information from 854: environment variables: 855: 856: $meteo->ua()->env_proxy(1); 857: 858: You can also replace the user agent entirely: 859: 860: use LWP::UserAgent::Throttled; 861: 862: my $ua = LWP::UserAgent::Throttled->new(); 863: $ua->throttle('open-meteo.com' => 1); 864: $meteo->ua($ua); 865: 866: =head3 EXAMPLE 867: 868: my $meteo = Weather::Meteo->new(); 869: 870: # Getter: inspect the current UA 871: my $ua = $meteo->ua(); 872: $ua->env_proxy(1); 873: 874: # Setter: replace with a throttled UA 875: use LWP::UserAgent::Throttled; 876: $meteo->ua(LWP::UserAgent::Throttled->new()); 877: 878: =head3 API SPECIFICATION 879: 880: =head4 Input 881: 882: When called with no arguments acts as a getter; the input schema is empty. 883: When called with an argument the argument must be an object that responds to C<get>: 884: 885: { ua => { type => 'object', can => 'get' } } 886: 887: =head4 Output 888: 889: { type => 'object', can => 'get' } 890: 891: =head3 MESSAGES 892: 893: Message Type Trigger 894: --------------------------------------------------- ----- ---------------------------------- 895: ua() requires a defined value croak ua(undef) called 896: must be an object that understands the get method croak ua arg lacks get() method 897: 898: =cut 899: 900: sub ua { โ—901 โ†’ 903 โ†’ 920 901: my $self = shift; 902: 903: if(@_) { 904: my $params = Params::Validate::Strict::validate_strict({ 905: args => Params::Get::get_params('ua', \@_), 906: schema => { 907: ua => { 908: type => 'object', 909: can => 'get' 910: } 911: } 912: }); 913: # Reject undef explicitly before it silently corrupts $self->{ua} 914: if(!defined($params->{ua})) { 915: $self->{'logger'}->error('ua() requires a defined value') if $self->{'logger'}; 916: Carp::croak('ua() requires a defined value'); 917: } 918: $self->{ua} = $params->{ua}; 919: } 920: return $self->{ua}; 921: } 922: 923: # --------------------------------------------------------------------------- 924: # _normalise_coord -- fix a coordinate that leads with a bare decimal point

Mutants (Total: 4, Killed: 1, Survived: 3)
925: # 926: # Purpose: Perl and many user inputs write ".5" where "0.5" is required. 927: # The regex /^-?\d+(\.\d+)?$/ rejects the bare-dot form, so we 928: # normalise before validating. 929: # Entry: $coord -- a coordinate string, possibly with a leading "." 930: # Exit: normalised string: ".5" -> "0.5", "-.5" -> "-0.5", others unchanged 931: # Side effects: none 932: # --------------------------------------------------------------------------- 933: sub _normalise_coord { โ—934 โ†’ 937 โ†’ 939 934: my ($coord) = @_; 935: my $result = $coord; 936: # Anchored with \z; atomic group on \d+ prevents O(n) backtracking 937: if(my ($frac) = $result =~ /\A-\.((?>\d+))\z/) { $result = "-0.$frac" } 938: elsif($result =~ /\A\./) { $result = "0$result" } 939: return $result; 940: } 941: 942: # --------------------------------------------------------------------------- 943: # _enforce_rate_limit -- sleep until enough time has passed since the last call

Mutants (Total: 1, Killed: 1, Survived: 0)

944: # 945: # Purpose: Prevent hammering the API when min_interval > 0. 946: # Entry: $self -- Weather::Meteo instance with last_request and min_interval 947: # Exit: (nothing returned) 948: # Side effects: may block for up to min_interval seconds via Time::HiRes::sleep 949: # ---------------------------------------------------------------------------

Mutants (Total: 1, Killed: 1, Survived: 0)

950: sub _enforce_rate_limit { โ—951 โ†’ 953 โ†’ 0 951: my ($self) = @_; 952: my $elapsed = time() - $self->{last_request}; 953: if($elapsed < $self->{min_interval}) { 954: Time::HiRes::sleep($self->{min_interval} - $elapsed); 955: } 956: } 957: 958: # --------------------------------------------------------------------------- 959: # _fetch_json -- HTTP GET a URL and decode the response body as JSON 960: #

Mutants (Total: 1, Killed: 1, Survived: 0)

961: # Purpose: Centralise the HTTP dispatch and JSON parsing steps that are 962: # common to all three public data methods, giving one place to 963: # maintain error-handling and rate-limiting logic. 964: # Entry: $self -- Weather::Meteo instance 965: # $url -- fully-formed request URL string 966: # Exit: decoded hashref (or other JSON value) on success; undef on error 967: # Side effects: enforces rate limit (may sleep); updates last_request timestamp; 968: # carps on HTTP errors and JSON parse failures 969: # --------------------------------------------------------------------------- 970: sub _fetch_json { โ—971 โ†’ 978 โ†’ 983 971: my ($self, $url) = @_; 972:

Mutants (Total: 1, Killed: 1, Survived: 0)

973: $self->_enforce_rate_limit(); 974: 975: my $res = $self->{ua}->get($url); 976: $self->{last_request} = time();

Mutants (Total: 1, Killed: 1, Survived: 0)

977: 978: unless(defined($res) && ref($res) && $res->can('is_error')) { 979: Carp::carp(ref($self) . ': UA->get did not return a valid HTTP response'); 980: return; 981: } 982: โ—983 โ†’ 983 โ†’ 988 983: if($res->is_error()) { 984: Carp::carp(ref($self) . ": $url API returned error: " . $res->status_line()); 985: return; 986: }

Mutants (Total: 1, Killed: 1, Survived: 0)

987:

Mutants (Total: 2, Killed: 2, Survived: 0)

โ—988 โ†’ 990 โ†’ 1000 988: my $rc; 989: eval { $rc = JSON::MaybeXS->new()->utf8()->decode($res->decoded_content()) }; 990: if($@) { 991: # Sanitise the exception: strip control chars and cap length to prevent 992: # log injection or flooding from a malicious API response body 993: my $err = "$@"; 994: $err =~ s/[[:cntrl:]]/ /g; 995: $err = substr($err, 0, 200) . '...' if length($err) > 200; 996: Carp::carp("Failed to parse JSON response: $err"); 997: return; 998: } 999: 1000: return $rc; 1001: } 1002: 1003: =head1 LIMITATIONS 1004: 1005: =over 4 1006: 1007: =item * Archive data lag 1008: 1009: The Open-Meteo archive endpoint has a lag of approximately five days before 1010: recent historical data becomes available. 1011: For dates within the past five days, 1012: C<weather()> may return C<undef> even when no error occurs. 1013: Use C<forecast()> or C<sunrise_sunset()> (without a date) to obtain data for 1014: today or recent days. 1015:

Mutants (Total: 2, Killed: 2, Survived: 0)

1016: =item * Coordinate range 1017: 1018: The module normalises coordinates with a bare leading decimal point (e.g. 1019: C<".5"> to C<"0.5">) but does not validate that latitude is within C<-90..90> 1020: or longitude within C<-180..180>. 1021: Out-of-range values are passed to the API, which may return an error. 1022: 1023: =item * No sub-hourly resolution 1024: 1025: The hourly data arrays always contain exactly 24 entries per day (one per hour). 1026: Sub-hourly resolution is not supported by this interface. 1027: 1028: =item * Per-process rate limiting 1029: 1030: The C<min_interval> rate limiter tracks the last request timestamp within a 1031: single process instance. 1032: Multiple concurrent processes or threads are not coordinated and may collectively 1033: exceed the desired request rate. 1034: 1035: =item * Timezone resolution requires an API key 1036: 1037: Automatic per-location timezone resolution requires setting the 1038: C<TIMEZONEDB_KEY> environment variable to a valid key from 1039: L<https://timezonedb.com>. 1040: Without it the module defaults to C<Europe/London> for all locations. 1041: 1042: =item * No list-context support 1043: 1044: C<weather()> and C<forecast()> enforce scalar/hashref context via 1045: L<Return::Set>. 1046: List context is not currently supported. 1047: 1048: =item * Access control by convention only 1049: 1050: Private methods (prefixed with C<_>) are not enforced by a module such as 1051: L<Sub::Private>. 1052: Callers are expected to treat them as internal; white-box test files may 1053: access them directly. 1054: 1055: =item * Host parameter restricted to plain DNS hostnames 1056: 1057: The C<host> constructor parameter (and the C<WEATHER__METEO__host> environment 1058: variable) must match C</\A[A-Za-z0-9][A-Za-z0-9.\-]*(:\d{1,5})?\z/>. 1059: IP addresses in CIDR notation, URLs with path components, C<@>-style 1060: user-info, and other special characters are rejected with a C<croak> to 1061: prevent Server-Side Request Forgery. 1062: If you need to test against a local service on a non-standard port, use a 1063: plain C<hostname:port> string (e.g. C<localhost:8080>). 1064: 1065: =item * Coordinate values limited to decimal numbers 1066: 1067: Latitude and longitude must match C</\A-?(?>\d+)(?:\.(?>\d+))?\z/> after 1068: leading-decimal normalisation. 1069: Exponential notation (C<1.5e2>), hex (C<0x1F>), and strings with embedded 1070: whitespace are rejected. 1071: Pass a pre-formatted decimal string rather than a Perl numeric expression if 1072: your caller might produce non-decimal representations. 1073: 1074: =back 1075: 1076: =head1 AUTHOR 1077: 1078: Nigel Horne, C<< <njh@nigelhorne.com> >> 1079: 1080: This library is free software; you can redistribute it and/or modify 1081: it under the same terms as Perl itself. 1082: 1083: Lots of thanks to the folks at L<https://open-meteo.com>. 1084: 1085: =head1 BUGS 1086: 1087: Please report any bugs or feature requests to C<bug-weather-meteo at rt.cpan.org>, 1088: or through the web interface at 1089: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Weather-Meteo>. 1090: I will be notified, and then you'll

Mutants (Total: 1, Killed: 1, Survived: 0)

1091: automatically be notified of progress on your bug as I make changes. 1092: 1093: =head1 SEE ALSO 1094: 1095: =over 4 1096: 1097: =item * Open Meteo API: L<https://open-meteo.com/en/docs#api_form> 1098: 1099: =item * L<Configure an Object at Runtime|Object::Configure> 1100: 1101: =item * L<Test Dashboard|https://nigelhorne.github.io/Weather-Meteo/coverage/>

Mutants (Total: 1, Killed: 1, Survived: 0)

1102: 1103: =back 1104: 1105: =head1 SUPPORT 1106: 1107: This module is provided as-is without any warranty.

Mutants (Total: 2, Killed: 2, Survived: 0)

1108: 1109: You can find documentation for this module with the perldoc command. 1110: 1111: perldoc Weather::Meteo 1112: 1113: You can also look for information at: 1114: 1115: =over 4 1116: 1117: =item * MetaCPAN 1118: 1119: L<https://metacpan.org/release/Weather-Meteo> 1120: 1121: =item * RT: CPAN's request tracker 1122: 1123: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Weather-Meteo> 1124:

Mutants (Total: 1, Killed: 1, Survived: 0)

1125: =item * CPANTS 1126:

Mutants (Total: 2, Killed: 2, Survived: 0)

1127: L<http://cpants.cpanauthors.org/dist/Weather-Meteo> 1128: 1129: =item * CPAN Testers' Matrix 1130: 1131: L<http://matrix.cpantesters.org/?dist=Weather-Meteo> 1132: 1133: =item * CPAN Testers Dependencies 1134: 1135: L<http://deps.cpantesters.org/?module=Weather-Meteo> 1136: 1137: =back 1138: 1139: =head1 FORMAL SPECIFICATION 1140:

Mutants (Total: 4, Killed: 4, Survived: 0)

1141: =head2 new 1142: 1143: ___ NEW ___________________________________________________ 1144: | class? : PACKAGE | Weather::Meteo | 1145: | params? : NAME |--> VALUE | 1146: |___________________________________________________________| 1147: | result! : Weather::Meteo | 1148: | | 1149: | blessed(result!) = 'Weather::Meteo' | 1150: | | 1151: | params?.ua? => result!.ua = params?.ua | 1152: | ~params?.ua => result!.ua : LWP::UserAgent | 1153: | params?.cache? => result!.cache = params?.cache | 1154: | ~params?.cache => result!.cache : CHI(Memory, global) | 1155: | params?.host? ^ valid_hostname(params?.host?) | 1156: | => result!.host = params?.host? | 1157: | params?.host? ^ ~valid_hostname(params?.host?) | 1158: | => croak /Invalid host/ | 1159: | valid_hostname(h) ::= h =~ /\A[A-Za-z0-9][A-Za-z0-9.\-]*(:\d{1,5})?\z/ | 1160: | ~params?.host? => result!.host = DEFAULT_HOST | 1161: | params?.min_interval? => result!.min_interval = params?.min_interval | 1162: | ~params?.min_interval => result!.min_interval = 0 | 1163: | result!.last_request = 0 | 1164: |___________________________________________________________| 1165: | |

Mutants (Total: 1, Killed: 1, Survived: 0)

1166: | PRE: class? is PACKAGE name or blessed Weather::Meteo | 1167: | POST: blessed(result!) = 'Weather::Meteo' | 1168: | forall k in params? . result!.k = params?.k | 1169: |___________________________________________________________| 1170:

Mutants (Total: 1, Killed: 1, Survived: 0)

1171: =head2 weather 1172: 1173: ___ WEATHER _______________________________________________ 1174: | self? : Weather::Meteo | 1175: | latitude? : REAL | 1176: | longitude? : REAL | 1177: | date? : DATE_STRING | strftime_OBJECT |

Mutants (Total: 1, Killed: 1, Survived: 0)

1178: | tz? : STRING (optional, default 'Europe/London')| 1179: |____________________________________________________________| 1180: | result! : HASHREF | undef | 1181: |____________________________________________________________| 1182: | |

Mutants (Total: 3, Killed: 0, Survived: 3)
1183: | PRE (~latitude? v ~longitude? v ~date?) | 1184: | => croak /Usage: weather\(latitude/ | 1185: | | 1186: | PRE lat? or lon? not matching | 1187: | /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ |

Mutants (Total: 2, Killed: 2, Survived: 0)

1188: | (after leading-decimal normalisation via _normalise_coord) | 1189: | => croak /Invalid latitude\/longitude format/ | 1190: | NOTE: list-context capture untaints lat/lon (perl -T) | 1191: | atomic groups eliminate O(n) backtracking | 1192: | | 1193: | PRE date? blessed ^ date?.can('strftime') | 1194: | => date? := date?.strftime('%F') | 1195: | PRE date? !~ /^\d{4}-\d{2}-\d{2}$/ | 1196: | => croak /Invalid date format. Expected YYYY-MM-DD/ | 1197: | | 1198: | PRE year(date?) < 1940 | 1199: | => result! = undef | 1200: | | 1201: | POST cache hit for (lat, lon, date, tz) | 1202: | => result! = cached_value | 1203: | | 1204: | POST HTTP error response | 1205: | => carp msg ^ result! = undef | 1206: | | 1207: | POST JSON parse failure | 1208: | => carp /Failed to parse JSON response/ ^ result! = undef | 1209: | | 1210: | POST response.error = true | 1211: | => carp /API error: reason/ ^ result! = undef | 1212: | | 1213: | POST ~response.hourly | 1214: | => result! = undef | 1215: | | 1216: | POST otherwise | 1217: | => result! = { hourly => HOURLY, daily => DAILY } | 1218: | cache.set(key, result!) | 1219: |____________________________________________________________| 1220: 1221: =head2 forecast 1222: 1223: ___ FORECAST ______________________________________________ 1224: | self? : Weather::Meteo | 1225: | latitude? : REAL | 1226: | longitude? : REAL | 1227: | days? : INTEGER [1..16] (optional, default 7) | 1228: | tz? : STRING (optional, default 'Europe/London')| 1229: |____________________________________________________________| 1230: | result! : HASHREF | undef | 1231: |____________________________________________________________| 1232: | | 1233: | PRE (~latitude? v ~longitude?) | 1234: | => croak /Usage: forecast\(latitude/ | 1235: | | 1236: | PRE lat? or lon? not matching | 1237: | /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ | 1238: | (after leading-decimal normalisation via _normalise_coord) | 1239: | => croak /Invalid latitude\/longitude format/ | 1240: | NOTE: list-context capture untaints lat/lon (perl -T) | 1241: | atomic groups eliminate O(n) backtracking | 1242: | | 1243: | PRE days? defined ^ (days? < 1 v days? > 16) | 1244: | => carp /days must be between 1 and 16/ | 1245: | days? := 7 | 1246: | | 1247: | POST cache hit for (lat, lon, days, tz) | 1248: | => result! = cached_value | 1249: | | 1250: | POST HTTP error response | 1251: | => carp msg ^ result! = undef | 1252: | | 1253: | POST JSON parse failure | 1254: | => carp /Failed to parse JSON response/ ^ result! = undef | 1255: | | 1256: | POST response.error = true | 1257: | => result! = undef | 1258: | | 1259: | POST ~response.hourly | 1260: | => result! = undef | 1261: | | 1262: | POST otherwise | 1263: | => result! = { hourly => HOURLY, daily => DAILY } | 1264: | cache.set(key, result!) | 1265: |____________________________________________________________| 1266: 1267: =head2 sunrise_sunset 1268: 1269: ___ SUNRISE_SUNSET ________________________________________ 1270: | self? : Weather::Meteo | 1271: | latitude? : REAL | 1272: | longitude? : REAL | 1273: | date? : DATE_STRING (optional, default today) | 1274: | tz? : STRING (optional, default 'Europe/London')| 1275: |____________________________________________________________| 1276: | result! : HASHREF | undef | 1277: |____________________________________________________________| 1278: | | 1279: | PRE (~latitude? v ~longitude?) | 1280: | => croak /Usage: sunrise_sunset\(latitude/ | 1281: | | 1282: | PRE lat? or lon? not matching | 1283: | /\A(-?(?>\d+)(?:\.(?>\d+))?)\z/ | 1284: | (after leading-decimal normalisation via _normalise_coord) | 1285: | => croak /Invalid latitude\/longitude format/ | 1286: | NOTE: list-context capture untaints lat/lon (perl -T) | 1287: | atomic groups eliminate O(n) backtracking | 1288: | | 1289: | PRE date? defined ^ date? !~ /^\d{4}-\d{2}-\d{2}$/ | 1290: | => carp /not a valid date/ ^ result! = undef | 1291: | | 1292: | POST ~date? v date? >= today | 1293: | => uses forecast endpoint (api.open-meteo.com) | 1294: | | 1295: | POST date? < today | 1296: | => uses archive endpoint (archive-api.open-meteo.com) | 1297: | | 1298: | POST cache hit for (lat, lon, date, tz) | 1299: | => result! = cached_value | 1300: | | 1301: | POST HTTP error or JSON failure or ~daily.sunrise | 1302: | => result! = undef | 1303: | | 1304: | POST otherwise | 1305: | => result! = { sunrise => ISO8601, sunset => ISO8601 } | 1306: | cache.set(key, result!) | 1307: |____________________________________________________________| 1308: 1309: =head2 ua 1310: 1311: ___ UA ____________________________________________________ 1312: | self? : Weather::Meteo | 1313: | ua? : OBJECT [can 'get'] (optional) | 1314: |____________________________________________________________| 1315: | result! : OBJECT [can 'get'] | 1316: |____________________________________________________________| 1317: | | 1318: | PRE ua? defined ^ ~ua?.can('get') | 1319: | => croak /must be an object that understands the get method/ | 1320: | | 1321: | POST ua? defined | 1322: | => self?.ua = ua? ^ result! = ua? | 1323: | | 1324: | POST ~ua? | 1325: | => result! = self?.ua (no state change) | 1326: |____________________________________________________________| 1327: 1328: =head1 LICENSE AND COPYRIGHT 1329: 1330: Copyright 2023-2026 Nigel Horne. 1331: 1332: Usage is subject to the GPL2 licence terms. 1333: If you use it, 1334: please let me know. 1335: 1336: =cut 1337: 1338: 1;