weather-sage 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/weather-sage/cache-entry.rb +13 -0
  3. data/lib/weather-sage/cache.rb +79 -0
  4. data/lib/weather-sage/census/geocoder.rb +48 -0
  5. data/lib/weather-sage/census/match.rb +19 -0
  6. data/lib/weather-sage/census.rb +7 -0
  7. data/lib/weather-sage/cli/commands/base-forecast.rb +70 -0
  8. data/lib/weather-sage/cli/commands/command.rb +42 -0
  9. data/lib/weather-sage/cli/commands/forecast.rb +28 -0
  10. data/lib/weather-sage/cli/commands/geocode.rb +51 -0
  11. data/lib/weather-sage/cli/commands/help.rb +82 -0
  12. data/lib/weather-sage/cli/commands/hourly.rb +28 -0
  13. data/lib/weather-sage/cli/commands/now.rb +87 -0
  14. data/lib/weather-sage/cli/commands/stations.rb +67 -0
  15. data/lib/weather-sage/cli/commands.rb +15 -0
  16. data/lib/weather-sage/cli/env/cache.rb +38 -0
  17. data/lib/weather-sage/cli/env/context.rb +29 -0
  18. data/lib/weather-sage/cli/env/env.rb +35 -0
  19. data/lib/weather-sage/cli/env/log.rb +41 -0
  20. data/lib/weather-sage/cli/env/vars.rb +20 -0
  21. data/lib/weather-sage/cli/env.rb +14 -0
  22. data/lib/weather-sage/cli/forecast.rb +70 -0
  23. data/lib/weather-sage/cli/help.rb +68 -0
  24. data/lib/weather-sage/cli.rb +29 -0
  25. data/lib/weather-sage/context.rb +13 -0
  26. data/lib/weather-sage/http/cache.rb +72 -0
  27. data/lib/weather-sage/http/error.rb +15 -0
  28. data/lib/weather-sage/http/fetcher.rb +79 -0
  29. data/lib/weather-sage/http/parser.rb +46 -0
  30. data/lib/weather-sage/http.rb +9 -0
  31. data/lib/weather-sage/weather/base-object.rb +36 -0
  32. data/lib/weather-sage/weather/forecast.rb +47 -0
  33. data/lib/weather-sage/weather/observation.rb +33 -0
  34. data/lib/weather-sage/weather/period.rb +44 -0
  35. data/lib/weather-sage/weather/point.rb +71 -0
  36. data/lib/weather-sage/weather/station.rb +43 -0
  37. data/lib/weather-sage/weather.rb +11 -0
  38. data/lib/weather-sage.rb +1 -1
  39. metadata +39 -3
  40. data/test/make-csvs.sh +0 -10
@@ -0,0 +1,29 @@
1
+ #
2
+ # Create Context from environment variables.
3
+ #
4
+ class WeatherSage::CLI::Env::Context < ::WeatherSage::Context
5
+ #
6
+ # Create Context from environment variables.
7
+ #
8
+ # The following environment variables are supported:
9
+ #
10
+ # - WEATHER_SAGE_LOG_LEVEL: Log level. One of "fatal", "error",
11
+ # "warning", "info", or "debug". Defaults to "warn".
12
+ #
13
+ # - WEATHER_SAGE_LOG_PATH: Path to log file. Defaults to standard
14
+ # error.
15
+ #
16
+ # - WEATHER_SAGE_CACHE_PATH: Path to HTTP cache file. Defaults to
17
+ # "~/.config/weather-sage/http-cache.pstore".
18
+ #
19
+ def initialize(env)
20
+ # create log from environment
21
+ log = ::WeatherSage::CLI::Env::Log.new(env)
22
+
23
+ # create cache from environment and log
24
+ cache = ::WeatherSage::CLI::Env::Cache.new(env, log)
25
+
26
+ # create context
27
+ super(log, cache)
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ #
2
+ # Environment wrapper.
3
+ #
4
+ class WeatherSage::CLI::Env::Env
5
+ #
6
+ # Create a new environment wrapper.
7
+ #
8
+ def initialize(env)
9
+ @env = env
10
+ end
11
+
12
+ #
13
+ # Get the value of the given environment variable.
14
+ #
15
+ def get(id, default = nil)
16
+ key = expand(id)
17
+ @env.key?(key) ? @env[key] : default
18
+ end
19
+
20
+ #
21
+ # Does the given ID exist in the environment?
22
+ #
23
+ def key?(id)
24
+ @env.key?(expand(id))
25
+ end
26
+
27
+ private
28
+
29
+ #
30
+ # Prefix ID to get full environment variable name.
31
+ #
32
+ def expand(id)
33
+ 'WEATHER_SAGE_' + id.upcase
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ require 'logger'
2
+
3
+ #
4
+ # Create Logger from environment variables.
5
+ #
6
+ class WeatherSage::CLI::Env::Log < ::Logger
7
+ #
8
+ # Default log level.
9
+ #
10
+ DEFAULT_LEVEL = 'WARN'
11
+
12
+ #
13
+ # Create Logger from environment variables.
14
+ #
15
+ # The following environment variables are supported:
16
+ #
17
+ # - WEATHER_SAGE_LOG_LEVEL: Log level. One of "fatal", "error",
18
+ # "warning", "info", or "debug". Defaults to "warn".
19
+ #
20
+ # - WEATHER_SAGE_LOG_PATH: Path to log file. Defaults to standard
21
+ # error.
22
+ #
23
+ def initialize(env)
24
+ # get log level (default to "warn" if unspecified)
25
+ level = (env.get('LOG_LEVEL', DEFAULT_LEVEL)).upcase
26
+
27
+ # create logger
28
+ super(
29
+ # log path (default to STDERR)
30
+ env.get('LOG_PATH', STDERR),
31
+
32
+ # log level (default to WARN)
33
+ level: ::Logger.const_get(level)
34
+ )
35
+
36
+ # log level
37
+ info('Env::Log#initialize') do
38
+ 'level = %p' % [level]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ module WeatherSage::CLI::Env
2
+ #
3
+ # Environment variables.
4
+ #
5
+ # Used by *help* command to print available environment variables.
6
+ #
7
+ VARS = [{
8
+ name: 'LOG_LEVEL',
9
+ text: "Log level (fatal, error, warning, info, debug)",
10
+ default: 'warn',
11
+ }, {
12
+ name: 'LOG_PATH',
13
+ text: "Path to log file",
14
+ default: 'standard error',
15
+ }, {
16
+ name: 'CACHE_PATH',
17
+ text: "Path to HTTP cache store",
18
+ default: "~/.config/weather-sage/http-cache.pstore",
19
+ }].freeze
20
+ end
@@ -0,0 +1,14 @@
1
+ module WeatherSage
2
+ module CLI
3
+ #
4
+ # Namespace for environment-related classes.
5
+ #
6
+ module Env
7
+ autoload :VARS, File.join(__dir__, 'env', 'vars.rb')
8
+ autoload :Env, File.join(__dir__, 'env', 'env.rb')
9
+ autoload :Log, File.join(__dir__, 'env', 'log.rb')
10
+ autoload :Cache, File.join(__dir__, 'env', 'cache.rb')
11
+ autoload :Context, File.join(__dir__, 'env', 'context.rb')
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,70 @@
1
+ #
2
+ # Namespace containing data for *forecast* and *hourly* commands.
3
+ #
4
+ module WeatherSage::CLI::Forecast
5
+ #
6
+ # List of forecast CSV columns and properties.
7
+ #
8
+ COLUMNS = [{
9
+ name: 'address',
10
+ prop: nil,
11
+ show: %i{forecast hourly_forecast},
12
+ }, {
13
+ name: 'name',
14
+ prop: 'name',
15
+ show: %i{forecast},
16
+ }, {
17
+ name: 'start_time',
18
+ prop: 'startTime',
19
+ show: %i{hourly_forecast},
20
+ }, {
21
+ name: 'end_time',
22
+ prop: 'endTime',
23
+ show: %i{hourly_forecast},
24
+ }, {
25
+ name: 'is_daytime',
26
+ prop: 'isDaytime',
27
+ show: [],
28
+ }, {
29
+ name: 'temperature',
30
+ prop: 'temperature',
31
+ show: %i{forecast hourly_forecast},
32
+ }, {
33
+ name: 'temperature_unit',
34
+ prop: 'temperatureUnit',
35
+ show: %i{forecast hourly_forecast},
36
+ }, {
37
+ name: 'temperature_trend',
38
+ prop: 'temperatureTrend',
39
+ show: [],
40
+ }, {
41
+ name: 'wind_speed',
42
+ prop: 'windSpeed',
43
+ show: %i{forecast hourly_forecast},
44
+ }, {
45
+ name: 'wind_direction',
46
+ prop: 'windDirection',
47
+ show: %i{forecast hourly_forecast},
48
+ }, {
49
+ name: 'icon',
50
+ prop: 'icon',
51
+ show: [],
52
+ }, {
53
+ name: 'short_forecast',
54
+ prop: 'shortForecast',
55
+ show: %i{forecast hourly_forecast},
56
+ }, {
57
+ name: 'detailed_forecast',
58
+ prop: 'detailedForecast',
59
+ show: [],
60
+ }].freeze
61
+
62
+ #
63
+ # Get columns for given forecast method and mode.
64
+ #
65
+ def self.columns(forecast_method, mode)
66
+ COLUMNS.select { |col|
67
+ (mode == :full) || col[:show].include?(forecast_method)
68
+ }
69
+ end
70
+ end
@@ -0,0 +1,68 @@
1
+ #
2
+ # Namespace for data for *help* command.
3
+ #
4
+ module WeatherSage::CLI::Help
5
+ #
6
+ # Help templates.
7
+ #
8
+ TEMPLATES = {
9
+ all: [
10
+ '%<app>s: Weather and geocoding utility.',
11
+ '',
12
+ 'This command uses the Census bureau geocoding API to convert',
13
+ 'street addresses to latitude/longitude coordinates, and the',
14
+ 'National Weather Service weather API to obtain observations',
15
+ 'from the nearest weather station.',
16
+ '',
17
+ 'Usage:',
18
+ ' %<app>s <command> [args...]',
19
+ '',
20
+ 'Commands:',
21
+ '%<cmds>s',
22
+ '',
23
+ 'Use "help <command>" to see detailed help for a command.',
24
+ '',
25
+ 'Environment Variables:',
26
+ '%<envs>s',
27
+ ].join("\n"),
28
+
29
+ one: [
30
+ '%<label>s %<line>s',
31
+ '',
32
+ '%<full>s',
33
+ ].join("\n"),
34
+
35
+ cmd: ' %-10<label>s %<line>s',
36
+
37
+ env: [
38
+ '* WEATHER_SAGE_%<name>s: %<text>s.',
39
+ ' Defaults to %<default>s.',
40
+ ].join("\n"),
41
+ }.freeze
42
+
43
+ #
44
+ # Build map of command name to command help.
45
+ #
46
+ # Note: The keys in this hash are sorted by command name.
47
+ #
48
+ COMMANDS = WeatherSage::CLI::Commands.constants.select { |sym|
49
+ sym.to_s.match(/^\w+Command$/)
50
+ }.sort.reduce({}) do |r, sym|
51
+ # limit to commands with a HELP constant
52
+ if WeatherSage::CLI::Commands.const_get(sym).const_defined?(:HELP)
53
+ # get help data
54
+ help = WeatherSage::CLI::Commands.const_get(sym).const_get(:HELP)
55
+
56
+ # build command id
57
+ id = sym.to_s.gsub(/Command$/, '').downcase
58
+
59
+ # add to results
60
+ r[id] = help.merge({
61
+ label: id + ':',
62
+ })
63
+ end
64
+
65
+ # return results
66
+ r
67
+ end.freeze
68
+ end
@@ -0,0 +1,29 @@
1
+ #
2
+ # Command-line interface for weather-sage.
3
+ #
4
+ module WeatherSage::CLI
5
+ autoload :Commands, File.join(__dir__, 'cli', 'commands.rb')
6
+ autoload :Env, File.join(__dir__, 'cli', 'env.rb')
7
+ autoload :Help, File.join(__dir__, 'cli', 'help.rb')
8
+ autoload :Forecast, File.join(__dir__, 'cli', 'forecast.rb')
9
+
10
+ #
11
+ # Entry point for command-line interface.
12
+ #
13
+ def self.run(app, args)
14
+ require 'csv'
15
+ require 'logger'
16
+ require 'fileutils'
17
+
18
+ args = ['help'] unless args.size > 0
19
+
20
+ # wrap environment and create context
21
+ env = Env::Env.new(ENV)
22
+ ctx = Env::Context.new(env)
23
+
24
+ # map first argument to command, then run it
25
+ (Commands.const_get('%sCommand' % [
26
+ args.shift.capitalize
27
+ ]) || Commands::HelpCommand).run(ctx, app, args)
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ #
2
+ # Minimal context containing a logger and HTTP request cache.
3
+ #
4
+ class WeatherSage::Context
5
+ attr :log, :cache
6
+
7
+ #
8
+ # Create context from given +log+ and +cache+.
9
+ #
10
+ def initialize(log, cache)
11
+ @log, @cache = log, cache
12
+ end
13
+ end
@@ -0,0 +1,72 @@
1
+ #
2
+ # HTTP cache backed by file.
3
+ #
4
+ class WeatherSage::HTTP::Cache
5
+ attr :path, :timeout
6
+
7
+ #
8
+ # Create an HTTP cache backed by file at +path+ where entries are
9
+ # valid for +timeout+ seconds.
10
+ #
11
+ # +timeout+ defaults to 30 minutes if unspecified.
12
+ #
13
+ def initialize(path, log, timeout = 30 * 60)
14
+ @path, @log, @timeout = path.freeze, log, timeout
15
+ @cache = ::WeatherSage::Cache.new(@path)
16
+ @fetcher = ::WeatherSage::HTTP::Fetcher.new(@log)
17
+ @parser = ::WeatherSage::HTTP::Parser.new(@log)
18
+ end
19
+
20
+ #
21
+ # Get cached URL, or request it if it is not cached.
22
+ #
23
+ def get(url, params = {})
24
+ # parse URL into URI, get key
25
+ uri = make_uri(url, params)
26
+ str = uri.to_s
27
+
28
+ @log.debug('HTTP::Cache#get') { '%s' % [str] }
29
+
30
+ unless r = @cache.get(str)
31
+ # fetch response, parse body, and cache result
32
+ r = @cache.set(str, parse(fetch(uri)), @timeout)
33
+ end
34
+
35
+ # return result
36
+ r
37
+ end
38
+
39
+ #
40
+ # Returns true if the given URL in the cache.
41
+ #
42
+ def key?(url, params = {})
43
+ @cache.key?(make_uri(url, params).to_s)
44
+ end
45
+
46
+ private
47
+
48
+ #
49
+ # Convert a URL and parameters to a URI.
50
+ #
51
+ def make_uri(url, params = {})
52
+ uri = URI.parse(url)
53
+ uri.query = URI.encode_www_form(params) if params.size > 0
54
+ uri
55
+ end
56
+
57
+ #
58
+ # Fetch URI, and return response.
59
+ #
60
+ # Raises an WeatherSage::HTTP::Error on error.
61
+ #
62
+ def fetch(uri)
63
+ @fetcher.fetch(uri)
64
+ end
65
+
66
+ #
67
+ # Parse HTTP response body.
68
+ #
69
+ def parse(resp)
70
+ @parser.parse(resp)
71
+ end
72
+ end
@@ -0,0 +1,15 @@
1
+ #
2
+ # HTTP error wrapper.
3
+ #
4
+ class WeatherSage::HTTP::Error < ::RuntimeError
5
+ attr :url, :code, :response
6
+
7
+ #
8
+ # Create Error instance from URL, response code, and response.
9
+ #
10
+ def initialize(url, code, resp)
11
+ @url = url.freeze
12
+ @code = code.freeze
13
+ @response = resp.freeze
14
+ end
15
+ end
@@ -0,0 +1,79 @@
1
+ require 'net/http'
2
+
3
+ #
4
+ # HTTP fetcher.
5
+ #
6
+ class WeatherSage::HTTP::Fetcher
7
+ #
8
+ # Request headers.
9
+ #
10
+ HEADERS = {
11
+ 'Accept' => 'application/json',
12
+ 'User-Agent' => "weather-sage/#{WeatherSage::VERSION}"
13
+ }.freeze
14
+
15
+ #
16
+ # Create an HTTP Fetcher.
17
+ #
18
+ def initialize(log)
19
+ @log = log
20
+ end
21
+
22
+ #
23
+ # Fetch URI, and return response.
24
+ #
25
+ # Raises an WeatherSage::HTTP::Error on error.
26
+ #
27
+ def fetch(uri, limit = 5)
28
+ # log uri
29
+ @log.info('Fetcher#fetch') { '%p' % [uri] }
30
+
31
+ # create request, set headers
32
+ req = Net::HTTP::Get.new(uri)
33
+ HEADERS.each { |k, v| req[k] = v }
34
+ use_ssl = (uri.scheme == 'https')
35
+
36
+ # connect, fetch response
37
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: use_ssl) do |http|
38
+ http.request(req)
39
+ end
40
+
41
+ # log response
42
+ @log.debug('Fetcher#fetch') { 'response: %p' % [resp] }
43
+
44
+ # check for error
45
+ case resp
46
+ when Net::HTTPSuccess
47
+ resp
48
+ when Net::HTTPRedirection
49
+ # have we hit the redirect limit?
50
+ unless limit > 0
51
+ # redirect limit hit, raise error
52
+ raise ::WeatherSage::HTTP::Error.new(uri.to_s, resp.code, resp)
53
+ end
54
+
55
+ # get new uri
56
+ new_uri = uri.merge(resp['location'])
57
+
58
+ # log redirect
59
+ @log.debug('Fetcher#fetch') do
60
+ 'redirect: %s' % [JSON.unparse({
61
+ location: resp['location'],
62
+ old_uri: uri,
63
+ new_uri: new_uri,
64
+ })]
65
+ end
66
+
67
+ # decriment limit, redirect
68
+ fetch(new_uri, limit - 1)
69
+ else
70
+ # log error
71
+ @log.debug('Fetcher#fetch') do
72
+ 'HTTP request failed: url = %s, response = %p' % [uri, resp]
73
+ end
74
+
75
+ # raise error
76
+ raise ::WeatherSage::HTTP::Error.new(uri.to_s, resp.code, resp)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ require 'json'
2
+
3
+ #
4
+ # HTTP response body parser.
5
+ #
6
+ class WeatherSage::HTTP::Parser
7
+ #
8
+ # Regex match for known JSON content types.
9
+ #
10
+ JSON_CONTENT_TYPE_REGEX =
11
+ /^application\/json|text\/json|application\/geo\+json/
12
+
13
+ #
14
+ # Create an HTTP response body parser.
15
+ #
16
+ def initialize(log)
17
+ @log = log
18
+ end
19
+
20
+ #
21
+ # Parse HTTP response body.
22
+ #
23
+ def parse(resp)
24
+ # FIXME: need to extract encoding from content-type
25
+ resp.body.force_encoding('UTF-8')
26
+
27
+ r = case resp.content_type
28
+ when JSON_CONTENT_TYPE_REGEX
29
+ # parse and return json
30
+ JSON.parse(resp.body)
31
+ else
32
+ # return string
33
+ resp.body
34
+ end
35
+
36
+ @log.debug('Parser#parse') do
37
+ JSON.unparse({
38
+ type: resp.content_type,
39
+ data: r,
40
+ })
41
+ end
42
+
43
+ # return response
44
+ r
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ #
2
+ # Namespace for HTTP classes.
3
+ #
4
+ module WeatherSage::HTTP
5
+ autoload :Error, File.join(__dir__, 'http', 'error.rb')
6
+ autoload :Parser, File.join(__dir__, 'http', 'parser.rb')
7
+ autoload :Fetcher, File.join(__dir__, 'http', 'fetcher.rb')
8
+ autoload :Cache, File.join(__dir__, 'http', 'cache.rb')
9
+ end
@@ -0,0 +1,36 @@
1
+ #
2
+ # Base class for weather API objects.
3
+ #
4
+ class WeatherSage::Weather::BaseObject
5
+ attr :cache
6
+
7
+ #
8
+ # Create a new weather object.
9
+ #
10
+ def initialize(ctx)
11
+ @ctx = ctx
12
+ end
13
+
14
+ protected
15
+
16
+ #
17
+ # URL format string for API requests.
18
+ #
19
+ API_URL = 'https://api.weather.gov/%s'
20
+
21
+ #
22
+ # Request given API endpoint, return response.
23
+ #
24
+ # FIXME: should handle errors too.
25
+ #
26
+ def get(path)
27
+ # build full URL
28
+ url = API_URL % [path]
29
+
30
+ # log full URL
31
+ @ctx.log.debug('BaseObject#get') { '%s' % [url] }
32
+
33
+ # get URL from cache
34
+ @ctx.cache.get(url)
35
+ end
36
+ end
@@ -0,0 +1,47 @@
1
+ require 'time'
2
+
3
+ #
4
+ # Numerical weather forecast.
5
+ #
6
+ class WeatherSage::Weather::Forecast
7
+ attr :data,
8
+ :updated_at,
9
+ :units,
10
+ :generator,
11
+ :generated_at,
12
+ :update_time,
13
+ :valid_times,
14
+ :elevation,
15
+ :periods
16
+
17
+ #
18
+ # Create new forecast object from given data.
19
+ #
20
+ def initialize(ctx, data)
21
+ # cache context and data, get properties
22
+ @ctx, @data = ctx, data.freeze
23
+ props = @data['properties']
24
+
25
+ # log data
26
+ @ctx.log.debug('Forecast#initialize') do
27
+ 'data = %p' % [@data]
28
+ end
29
+
30
+ @updated_at = Time.parse(props['updated'])
31
+ @units = props['units']
32
+ @generator = props['generator']
33
+ @generated_at = Time.parse(props['generatedAt'])
34
+ @update_time = Time.parse(props['updateTime'])
35
+ @valid_times = props['validTimes']
36
+ @elevation = props['elevation']['value']
37
+ end
38
+
39
+ #
40
+ # Return an array of periods for this forecast.
41
+ #
42
+ def periods
43
+ @periods ||= @data['properties']['periods'].map { |row|
44
+ ::WeatherSage::Weather::Period.new(@ctx, row)
45
+ }
46
+ end
47
+ end
@@ -0,0 +1,33 @@
1
+ #
2
+ # Weather station observation metadata.
3
+ #
4
+ module WeatherSage::Weather::Observation
5
+ #
6
+ # Observation property types.
7
+ #
8
+ PROPERTIES = {
9
+ timestamp: :time,
10
+ textDescription: :text,
11
+ rawMessage: :text,
12
+ icon: :url,
13
+
14
+ temperature: :value,
15
+ dewpoint: :value,
16
+ windDirection: :value,
17
+ windSpeed: :value,
18
+ windGust: :value,
19
+ barometricPressure: :value,
20
+ seaLevelPressure: :value,
21
+ visibility: :value,
22
+ maxTemperatureLast24Hours: :value,
23
+ minTemperatureLast24Hours: :value,
24
+ precipitationLastHour: :value,
25
+ precipitationLast3Hours: :value,
26
+ precipitationLast6Hours: :value,
27
+ relativeHumidity: :value,
28
+ windChill: :value,
29
+ heatIndex: :value,
30
+
31
+ cloudLayers: :cloud,
32
+ }.freeze
33
+ end