nws 0.2.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e20ffd779d08818af88ceebe121319e131fc1510b2c8a613dc2a6aa289fd94bb
4
+ data.tar.gz: ebe575f2294c397f3cc9e614bf42786635766cb03f80abe3479d6adbffcb4859
5
+ SHA512:
6
+ metadata.gz: 5ab2b2ab1fab199ea512c5dc85ebfb9d4e24232b2ff75163f505f59a25536a7764550f5c92fabd936e5a9b7469cfa77e02ac9fe286717269f641a2ab31eca00c
7
+ data.tar.gz: cc8d57ccfb357711376b5945244e641d3faeee4495e549ac7e0d8de24f34b2899a8f24b01f01c617c042ab2c70d837e0adb45a081d1811f77faccf519debedbb
data/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # NWS
2
+
3
+ Ruby client for the National Weather Service API.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ gem install nws
9
+ ```
10
+
11
+ ## CLI Usage
12
+
13
+ ```bash
14
+ nws forecast --location "Denver, CO"
15
+ nws hourly --lat 39.7456 --lon -104.9892
16
+ nws current --location "80202"
17
+ nws alerts --state CO
18
+ ```
19
+
20
+ Use `-o json` for JSON output.
21
+
22
+ ## Library Usage
23
+
24
+ ```ruby
25
+ require 'nws'
26
+
27
+ # 7-day forecast
28
+ forecast = NWS.forecast(39.7456, -104.9892)
29
+ puts forecast.today
30
+
31
+ # Hourly forecast
32
+ hourly = NWS.hourly_forecast(39.7456, -104.9892)
33
+ hourly.next_hours(12).each { |h| puts h }
34
+
35
+ # Current conditions
36
+ conditions = NWS.current_conditions(39.7456, -104.9892)
37
+ puts conditions.summary
38
+
39
+ # Alerts
40
+ alerts = NWS.alerts(state: "CO")
41
+ alerts.each { |a| puts a.headline }
42
+ ```
43
+
44
+ ## Geocoding
45
+
46
+ Location lookups use OpenStreetMap Nominatim with rate limiting and caching.
47
+
48
+ ## License
49
+
50
+ Public domain. See LICENSE.
51
+
52
+ ## Note
53
+
54
+ This code and repository are unofficial and unaffiliated with the National
55
+ Weathers Service, NOAA, and any other government or official bodies.
data/bin/nws ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require_relative "../lib/nws"
5
+
6
+ module NWS
7
+ class CLI
8
+ def initialize(args)
9
+ @args = args
10
+ @options = {}
11
+ end
12
+
13
+ def run
14
+ command = @args.shift
15
+
16
+ case command
17
+ when "forecast", "f"
18
+ forecast_command
19
+ when "hourly", "h"
20
+ hourly_command
21
+ when "current", "c", "conditions"
22
+ current_command
23
+ when "alerts", "a"
24
+ alerts_command
25
+ when "point", "p"
26
+ point_command
27
+ when "stations", "s"
28
+ stations_command
29
+ when "help", nil, "-h", "--help"
30
+ help_command
31
+ when "version", "-v", "--version"
32
+ version_command
33
+ else
34
+ puts "Unknown command: #{command}"
35
+ puts "Run 'nws help' for usage information."
36
+ exit 1
37
+ end
38
+ rescue NWS::InvalidCoordinatesError => e
39
+ puts "Error: #{e.message}"
40
+ exit 1
41
+ rescue NWS::NotFoundError => e
42
+ puts "Error: Location not found. Please check your coordinates."
43
+ exit 1
44
+ rescue NWS::RateLimitError => e
45
+ puts "Error: Rate limit exceeded. Please wait a few seconds and try again."
46
+ exit 1
47
+ rescue NWS::APIError => e
48
+ puts "API Error: #{e.message}"
49
+ exit 1
50
+ rescue StandardError => e
51
+ puts "Error: #{e.message}"
52
+ exit 1
53
+ end
54
+
55
+ private
56
+
57
+ def parse_location_options(parser)
58
+ parser.on("--lat LAT", Float, "Latitude") { |v| @options[:lat] = v }
59
+ parser.on("--lon LON", Float, "Longitude") { |v| @options[:lon] = v }
60
+ parser.on("--location LOCATION", "Coordinates (LAT,LON) or place name") do |v|
61
+ @options[:location_input] = v
62
+ end
63
+ end
64
+
65
+ def parse_output_format_option(parser)
66
+ parser.on("-o", "--output-format FORMAT", %w[text json], "Output format: text (default), json") do |v|
67
+ @options[:output_format] = v
68
+ end
69
+ end
70
+
71
+ def json_output?
72
+ @options[:output_format] == "json"
73
+ end
74
+
75
+ def output(text_output, json_data)
76
+ if json_output?
77
+ puts JSON.pretty_generate(json_data)
78
+ else
79
+ puts text_output
80
+ print_attribution
81
+ end
82
+ end
83
+
84
+ def require_location!
85
+ resolve_location! if @options[:location_input]
86
+
87
+ unless @options[:lat] && @options[:lon]
88
+ puts "Error: Location required. Use --lat and --lon or --location"
89
+ exit 1
90
+ end
91
+ end
92
+
93
+ def resolve_location!
94
+ input = @options[:location_input]
95
+ return unless input
96
+
97
+ if coordinate_format?(input)
98
+ lat, lon = input.split(",").map(&:strip).map(&:to_f)
99
+ @options[:lat] = lat
100
+ @options[:lon] = lon
101
+ else
102
+ geocoder = NWS::Geocoder.new
103
+ result = geocoder.geocode(input)
104
+ @options[:lat] = result.latitude
105
+ @options[:lon] = result.longitude
106
+ @options[:resolved_location] = result.display_name
107
+ @options[:geocoded] = true
108
+ @options[:from_cache] = result.from_cache?
109
+ end
110
+ end
111
+
112
+ def print_attribution
113
+ return unless @options[:geocoded]
114
+ puts ""
115
+ puts NWS::Geocoder.attribution
116
+ end
117
+
118
+ def coordinate_format?(input)
119
+ # Check if input looks like "lat,lon" (two numbers separated by comma)
120
+ parts = input.split(",")
121
+ return false unless parts.length == 2
122
+
123
+ parts.all? do |part|
124
+ part.strip.match?(/\A-?\d+(\.\d+)?\z/)
125
+ end
126
+ end
127
+
128
+ def forecast_command
129
+ parser = OptionParser.new do |opts|
130
+ opts.banner = "Usage: nws forecast [options]"
131
+ parse_location_options(opts)
132
+ parse_output_format_option(opts)
133
+ opts.on("--detailed", "-d", "Show detailed forecast") { @options[:detailed] = true }
134
+ end
135
+ parser.parse!(@args)
136
+ require_location!
137
+
138
+ forecast = NWS.forecast(@options[:lat], @options[:lon])
139
+
140
+ text_output = @options[:detailed] ? forecast.detailed : forecast.to_s
141
+ json_data = {
142
+ location: forecast.point&.location_string,
143
+ updated_at: forecast.updated_at&.iso8601,
144
+ periods: forecast.periods.map do |p|
145
+ {
146
+ name: p.name,
147
+ start_time: p.start_time&.iso8601,
148
+ end_time: p.end_time&.iso8601,
149
+ is_daytime: p.daytime?,
150
+ temperature: p.temperature,
151
+ temperature_unit: p.temperature_unit,
152
+ wind_speed: p.wind_speed,
153
+ wind_direction: p.wind_direction,
154
+ short_forecast: p.short_forecast,
155
+ detailed_forecast: p.detailed_forecast,
156
+ probability_of_precipitation: p.probability_of_precipitation
157
+ }
158
+ end
159
+ }
160
+
161
+ output(text_output, json_data)
162
+ end
163
+
164
+ def hourly_command
165
+ parser = OptionParser.new do |opts|
166
+ opts.banner = "Usage: nws hourly [options]"
167
+ parse_location_options(opts)
168
+ parse_output_format_option(opts)
169
+ opts.on("--hours N", Integer, "Number of hours to show (default: 24)") { |v| @options[:hours] = v }
170
+ end
171
+ parser.parse!(@args)
172
+ require_location!
173
+
174
+ forecast = NWS.hourly_forecast(@options[:lat], @options[:lon])
175
+ hours = @options[:hours] || 24
176
+
177
+ text_output = forecast.to_s(hours: hours)
178
+ json_data = {
179
+ location: forecast.point&.location_string,
180
+ updated_at: forecast.updated_at&.iso8601,
181
+ periods: forecast.next_hours(hours).map do |p|
182
+ {
183
+ start_time: p.start_time&.iso8601,
184
+ end_time: p.end_time&.iso8601,
185
+ is_daytime: p.daytime?,
186
+ temperature: p.temperature,
187
+ temperature_unit: p.temperature_unit,
188
+ wind_speed: p.wind_speed,
189
+ wind_direction: p.wind_direction,
190
+ short_forecast: p.short_forecast,
191
+ probability_of_precipitation: p.probability_of_precipitation
192
+ }
193
+ end
194
+ }
195
+
196
+ output(text_output, json_data)
197
+ end
198
+
199
+ def current_command
200
+ parser = OptionParser.new do |opts|
201
+ opts.banner = "Usage: nws current [options]"
202
+ parse_location_options(opts)
203
+ parse_output_format_option(opts)
204
+ end
205
+ parser.parse!(@args)
206
+ require_location!
207
+
208
+ observation = NWS.current_conditions(@options[:lat], @options[:lon])
209
+
210
+ text_output = observation.to_s
211
+ json_data = {
212
+ timestamp: observation.timestamp&.iso8601,
213
+ text_description: observation.text_description,
214
+ temperature: {
215
+ celsius: observation.temperature_c,
216
+ fahrenheit: observation.temperature_f&.round(1)
217
+ },
218
+ dewpoint: {
219
+ celsius: observation.dewpoint_c,
220
+ fahrenheit: observation.dewpoint_f&.round(1)
221
+ },
222
+ relative_humidity: observation.relative_humidity&.round(1),
223
+ wind: {
224
+ speed_mph: observation.wind_speed_mph&.round(1),
225
+ speed_kmh: observation.wind_speed_kmh,
226
+ direction: observation.wind_direction,
227
+ gust_mph: observation.wind_gust_mph&.round(1)
228
+ },
229
+ visibility: {
230
+ meters: observation.visibility_m,
231
+ miles: observation.visibility_miles
232
+ },
233
+ barometric_pressure: {
234
+ pascals: observation.barometric_pressure,
235
+ inhg: observation.barometric_pressure_inhg
236
+ },
237
+ heat_index: {
238
+ celsius: observation.heat_index_c,
239
+ fahrenheit: observation.heat_index_f&.round(1)
240
+ },
241
+ wind_chill: {
242
+ celsius: observation.wind_chill_c,
243
+ fahrenheit: observation.wind_chill_f&.round(1)
244
+ }
245
+ }
246
+
247
+ output(text_output, json_data)
248
+ end
249
+
250
+ def alerts_command
251
+ parser = OptionParser.new do |opts|
252
+ opts.banner = "Usage: nws alerts [options]"
253
+ parse_location_options(opts)
254
+ parse_output_format_option(opts)
255
+ opts.on("--state STATE", "Two-letter state code (e.g., CA, TX)") { |v| @options[:state] = v }
256
+ opts.on("--detailed", "-d", "Show detailed alerts") { @options[:detailed] = true }
257
+ end
258
+ parser.parse!(@args)
259
+
260
+ resolve_location! if @options[:location_input]
261
+
262
+ alerts = if @options[:state]
263
+ NWS.alerts(state: @options[:state])
264
+ elsif @options[:lat] && @options[:lon]
265
+ NWS.alerts(point: [@options[:lat], @options[:lon]])
266
+ else
267
+ puts "Error: Provide --state or --lat/--lon"
268
+ exit 1
269
+ end
270
+
271
+ if json_output?
272
+ json_data = {
273
+ count: alerts.count,
274
+ alerts: alerts.map do |alert|
275
+ {
276
+ id: alert.id,
277
+ event: alert.event,
278
+ severity: alert.severity,
279
+ certainty: alert.certainty,
280
+ urgency: alert.urgency,
281
+ area_desc: alert.area_desc,
282
+ headline: alert.headline,
283
+ description: alert.description,
284
+ instruction: alert.instruction,
285
+ effective: alert.effective&.iso8601,
286
+ expires: alert.expires&.iso8601,
287
+ onset: alert.onset&.iso8601,
288
+ sender_name: alert.sender_name
289
+ }
290
+ end
291
+ }
292
+ puts JSON.pretty_generate(json_data)
293
+ else
294
+ if alerts.empty?
295
+ puts "No active alerts."
296
+ else
297
+ puts "#{alerts.count} active alert(s):\n\n"
298
+ alerts.each do |alert|
299
+ if @options[:detailed]
300
+ puts alert.detailed
301
+ else
302
+ puts alert.to_s
303
+ end
304
+ puts "\n" + "-" * 60 + "\n\n"
305
+ end
306
+ end
307
+ print_attribution
308
+ end
309
+ end
310
+
311
+ def point_command
312
+ parser = OptionParser.new do |opts|
313
+ opts.banner = "Usage: nws point [options]"
314
+ parse_location_options(opts)
315
+ parse_output_format_option(opts)
316
+ end
317
+ parser.parse!(@args)
318
+ require_location!
319
+
320
+ point = NWS.point(@options[:lat], @options[:lon])
321
+
322
+ text_output = <<~TEXT
323
+ Point Information
324
+ Location: #{point.location_string}
325
+ Grid: #{point.grid_id} (#{point.grid_x}, #{point.grid_y})
326
+ Time Zone: #{point.time_zone}
327
+ Forecast URL: #{point.forecast_url}
328
+ Hourly Forecast URL: #{point.forecast_hourly_url}
329
+ Observation Stations URL: #{point.observation_stations_url}
330
+ TEXT
331
+
332
+ json_data = {
333
+ location: point.location_string,
334
+ city: point.city,
335
+ state: point.state,
336
+ latitude: point.latitude,
337
+ longitude: point.longitude,
338
+ grid_id: point.grid_id,
339
+ grid_x: point.grid_x,
340
+ grid_y: point.grid_y,
341
+ time_zone: point.time_zone,
342
+ forecast_url: point.forecast_url,
343
+ forecast_hourly_url: point.forecast_hourly_url,
344
+ observation_stations_url: point.observation_stations_url
345
+ }
346
+
347
+ output(text_output.strip, json_data)
348
+ end
349
+
350
+ def stations_command
351
+ parser = OptionParser.new do |opts|
352
+ opts.banner = "Usage: nws stations [options]"
353
+ parse_location_options(opts)
354
+ parse_output_format_option(opts)
355
+ opts.on("--limit N", Integer, "Number of stations to show") { |v| @options[:limit] = v }
356
+ end
357
+ parser.parse!(@args)
358
+ require_location!
359
+
360
+ point = NWS.point(@options[:lat], @options[:lon])
361
+ stations = point.observation_stations
362
+ limit = @options[:limit] || 5
363
+ limited_stations = stations.first(limit)
364
+
365
+ text_lines = ["Observation Stations near #{point.location_string}:\n"]
366
+ limited_stations.each_with_index do |station, i|
367
+ text_lines << "#{i + 1}. #{station.name} (#{station.station_id})"
368
+ text_lines << " Location: #{station.latitude}, #{station.longitude}"
369
+ text_lines << ""
370
+ end
371
+
372
+ json_data = {
373
+ location: point.location_string,
374
+ stations: limited_stations.map do |station|
375
+ {
376
+ station_id: station.station_id,
377
+ name: station.name,
378
+ latitude: station.latitude,
379
+ longitude: station.longitude,
380
+ elevation: station.elevation,
381
+ time_zone: station.time_zone
382
+ }
383
+ end
384
+ }
385
+
386
+ output(text_lines.join("\n"), json_data)
387
+ end
388
+
389
+ def help_command
390
+ puts <<~HELP
391
+ NWS - National Weather Service API Client
392
+
393
+ Usage: nws <command> [options]
394
+
395
+ Commands:
396
+ forecast, f Get 7-day forecast
397
+ hourly, h Get hourly forecast
398
+ current, c Get current conditions
399
+ alerts, a Get active weather alerts
400
+ point, p Get point/grid information
401
+ stations, s List observation stations
402
+
403
+ Location Options (required for most commands):
404
+ --lat LAT Latitude
405
+ --lon LON Longitude
406
+ --location LOCATION Coordinates (LAT,LON) or place name
407
+
408
+ Output Options:
409
+ -o, --output-format FORMAT Output format: text (default), json
410
+
411
+ Examples:
412
+ nws forecast --location "Washington, DC"
413
+ nws forecast --lat 38.8977 --lon -77.0365
414
+ nws hourly --location 40.7128,-74.0060 --hours 12
415
+ nws current --location "New York, NY" -o json
416
+ nws alerts --state CA
417
+ nws alerts --location "Los Angeles, CA" --output-format json
418
+
419
+ Run 'nws <command> --help' for command-specific options.
420
+ HELP
421
+ end
422
+
423
+ def version_command
424
+ puts "nws #{NWS::VERSION}"
425
+ end
426
+ end
427
+ end
428
+
429
+ NWS::CLI.new(ARGV).run