Skip to content

Commit ebcf58e

Browse files
committed
Add METARFormatting target for locale-aware METAR and TAF formatting
* Adds new METARFormatting library target and product * Adds documentation for METARFormatting * Adds DecodeMETAR and DecodeTAF CLI tools to demonstrate formatting library * Removes METARGauntlet as the functionality is now included in the new CLI tools
1 parent 8b02566 commit ebcf58e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+3736
-97
lines changed

Package.resolved

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@ let package = Package(
1111
.library(
1212
name: "SwiftMETAR",
1313
targets: ["SwiftMETAR"]),
14-
.executable(name: "SwiftMETARGauntlet", targets: ["SwiftMETARGauntlet"])
14+
.library(
15+
name: "METARFormatting",
16+
targets: ["METARFormatting"]),
17+
.executable(name: "decode-metar", targets: ["DecodeMETAR"]),
18+
.executable(name: "decode-taf", targets: ["DecodeTAF"]),
1519
],
1620
dependencies: [
1721
// Dependencies declare other packages that this package depends on.
1822
.package(url: "https://github.com/Quick/Quick.git", from: "7.6.2"),
1923
.package(url: "https://github.com/Quick/Nimble.git", from: "13.6.0"),
2024
.package(url: "https://github.com/objecthub/swift-numberkit.git", from: "2.6.0"),
2125
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.4.3"),
22-
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0")
26+
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
27+
.package(url: "https://github.com/Mr-Alirezaa/BuildableMacro.git", from: "0.5.0"),
2328
],
2429
targets: [
2530
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@@ -28,15 +33,27 @@ let package = Package(
2833
name: "SwiftMETAR",
2934
dependencies: [.product(name: "NumberKit", package: "swift-numberkit")],
3035
resources: [.process("Resources")]),
36+
.target(
37+
name: "METARFormatting",
38+
dependencies: [
39+
"SwiftMETAR",
40+
.product(name: "BuildableMacro", package: "BuildableMacro")],
41+
resources: [.process("Resources")]),
3142
.testTarget(
3243
name: "SwiftMETARTests",
3344
dependencies: ["SwiftMETAR", "Quick", "Nimble"]),
3445
.executableTarget(
35-
name: "SwiftMETARGauntlet",
46+
name: "DecodeMETAR",
47+
dependencies: [
48+
"SwiftMETAR",
49+
"METARFormatting",
50+
.product(name: "ArgumentParser", package: "swift-argument-parser")]),
51+
.executableTarget(
52+
name: "DecodeTAF",
3653
dependencies: [
3754
"SwiftMETAR",
38-
.product(name: "ArgumentParser", package: "swift-argument-parser")
39-
])
55+
"METARFormatting",
56+
.product(name: "ArgumentParser", package: "swift-argument-parser")])
4057
],
4158
swiftLanguageModes: [.v5, .v6]
4259
)

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ original data (day-hour-minute) rather than generating timestamps. Both `METAR`
7373
have vars allowing you to retrieve these values as `Date`s, but the data is stored as
7474
components.
7575

76+
### Formatting and Localization
77+
78+
See the `METARFormatting` package (which has its own documentation bundle) for
79+
information on how to use SwiftMETAR classes with localization libraries.
80+
7681
## Documentation
7782

7883
Online API documentation and tutorials are available at
@@ -91,11 +96,17 @@ docarchive file in Xcode for browseable API documentation. Or, within Xcode,
9196
open the SwiftMETAR package in Xcode and choose **Build Documentation** from the
9297
**Product** menu.
9398

99+
To generate documentation for METARFormatting, run
100+
101+
```sh
102+
swift package generate-documentation --target METARFormatting
103+
```
104+
94105
## Tests
95106

96107
Unit testing is done using Nimble and Quick. Simply test the `SwiftMETAR` target to run
97108
tests.
98109

99-
A `SwiftMETAR_Gauntlet` target is also available to do an end-to-end test with live data.
100-
This will download METARs and TAFs from the AWC server and attempt to parse them. Any
101-
failures will be logged with the failing string.
110+
The `DecodeMETAR` and `DecodeTAF` targets provide command-line tools that allow
111+
you to decode METARs and TAFs into human-readable text. They demonstrate the
112+
locale-aware formatting tools available with the `METARFormatting` library.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import Foundation
2+
import ArgumentParser
3+
import SwiftMETAR
4+
import METARFormatting
5+
6+
@available(macOS 15.0, *)
7+
@main
8+
struct DecodeMETAR: AsyncParsableCommand {
9+
static let configuration = CommandConfiguration(
10+
abstract: "Decodes a METAR into human-readable text.",
11+
discussion: """
12+
This tool can be used with an airport code (in ICAO format [i.e., include the "K"]),
13+
in which case it will download the latest METARs from AWC; or it can be used with
14+
a raw METAR string, in which case that string will be parsed.
15+
""")
16+
17+
@Argument(help: "The ICAO code of an airport, or a METAR string to decode")
18+
var airportCodeOrMETAR: String? = nil
19+
20+
@Option(name: [.customLong("metar-url"), .short], help: "The URL to load the METAR CSV from.", transform: { URL(string: $0)! })
21+
var METAR_URL = URL(string: "https://aviationweather.gov/data/cache/metars.cache.csv")!
22+
23+
@Flag(name: .long, inversion: .prefixedNo, help: "Include raw METAR text")
24+
var raw = false
25+
26+
@Flag(name: .shortAndLong, inversion: .prefixedNo, help: "Include remarks")
27+
var remarks = true
28+
29+
@Flag(name: .long, help: "Parse and decode all METARs (!)")
30+
var all = false
31+
32+
@Flag(name: .long, help: "Only show stations with parsing errors (intended to be used with `--all`")
33+
var errorsOnly = false
34+
35+
private var session: URLSession { .init(configuration: .ephemeral) }
36+
37+
func run() async throws {
38+
try await all ? parseAll() : parsePrompt()
39+
}
40+
41+
private func parseAll() async throws {
42+
let METARs = try await loadMETARs { raw, error in
43+
print(raw)
44+
print(" Parse error: \(error.localizedDescription)")
45+
print()
46+
}
47+
if !errorsOnly {
48+
for metar in METARs.values {
49+
printMETAR(metar)
50+
}
51+
}
52+
}
53+
54+
private func parsePrompt() async throws {
55+
let airportCodeOrMETAR = promptMETAR()
56+
let metar = try await airportCodeOrMETAR.count == 4 ? parse(code: airportCodeOrMETAR) : parse(raw: airportCodeOrMETAR)
57+
printMETAR(metar)
58+
}
59+
60+
private func promptMETAR() -> String {
61+
var airportCodeOrMETAR = self.airportCodeOrMETAR
62+
63+
while airportCodeOrMETAR == nil || airportCodeOrMETAR!.isEmpty {
64+
print("Enter airport code or METAR: ", terminator: "")
65+
airportCodeOrMETAR = readLine(strippingNewline: true)
66+
}
67+
68+
return airportCodeOrMETAR!
69+
}
70+
71+
private func parse(code: String) async throws -> METAR {
72+
guard let metar = try await getMETAR(airportCode: code) else {
73+
throw Errors.unknownAirportID(code)
74+
}
75+
return metar
76+
}
77+
78+
private func parse(raw: String) async throws -> METAR {
79+
try await METAR.from(string: raw)
80+
}
81+
82+
private func loadMETARs(errorHandler: ((String, Swift.Error) throws -> Void)) async throws -> Dictionary<String, METAR> {
83+
print("Loading METARs…")
84+
print()
85+
86+
let (data, response) = try await session.bytes(from: METAR_URL)
87+
guard let response = response as? HTTPURLResponse else {
88+
throw Errors.badResponse(response)
89+
}
90+
guard response.statusCode/100 == 2 else {
91+
throw Errors.badStatus(response: response)
92+
}
93+
94+
var METARs = Dictionary<String, METAR>()
95+
for try await line in data.lines {
96+
guard let range = line.rangeOfCharacter(from: CharacterSet(charactersIn: ",")) else { continue }
97+
let string = String(line[line.startIndex..<range.lowerBound])
98+
guard string.starts(with: "K") else { continue }
99+
do {
100+
let metar = try await METAR.from(string: string)
101+
METARs[metar.stationID] = metar
102+
} catch {
103+
try errorHandler(string, error)
104+
}
105+
}
106+
107+
return METARs
108+
}
109+
110+
private func getMETAR(airportCode: String) async throws -> METAR? {
111+
let METARs = try await loadMETARs { raw, error in
112+
if raw.starts(with: airportCode) {
113+
throw Errors.badMETAR(raw: raw)
114+
}
115+
}
116+
117+
return METARs[airportCode]
118+
}
119+
120+
private func printMETAR(_ metar: METAR) {
121+
print("Airport: \(metar.stationID)")
122+
if raw, let text = metar.text {
123+
print(text)
124+
}
125+
126+
lprint("Issued: \(metar.date, format: .dateTime) (\(metar.issuance, format: .issuance))")
127+
lprint("Observer: \(metar.observer, format: .observer)")
128+
if let wind = metar.wind {
129+
lprint("Wind: \(wind, format: .wind)")
130+
}
131+
if let visibility = metar.visibility {
132+
lprint("Visibility: \(visibility, format: .visibility)")
133+
}
134+
for visibility in metar.runwayVisibility {
135+
lprint("\(visibility.runwayID) Visibility: \(visibility.visibility, format: .visibility)")
136+
}
137+
if let weathers = metar.weather, !weathers.isEmpty {
138+
lprint("Weather: \(weathers, format: .list(memberStyle: .weather, type: .and))")
139+
}
140+
if !metar.conditions.isEmpty {
141+
lprint("Conditions: \(metar.conditions, format: .list(memberStyle: .condition, type: .and))")
142+
}
143+
if let temperature = metar.temperatureMeasurement {
144+
lprint("Temperature: \(temperature, format: .measurement(width: .abbreviated, usage: .asProvided))")
145+
}
146+
if let dewpoint = metar.dewpointMeasurement {
147+
lprint("Dewpoint: \(dewpoint, format: .measurement(width: .abbreviated, usage: .asProvided))")
148+
}
149+
if let altimeter = metar.altimeter?.measurement {
150+
lprint("Altimeter: \(altimeter, format: .measurement(width: .abbreviated, usage: .asProvided))")
151+
}
152+
153+
if remarks {
154+
print()
155+
printRemarks(metar: metar)
156+
}
157+
158+
print()
159+
}
160+
161+
private func printRemarks(metar: METAR) {
162+
for remark in metar.remarks.sorted(using: RemarkComparator()) {
163+
print(RemarkEntry.FormatStyle.remark().format(remark))
164+
}
165+
}
166+
167+
private func lprint(_ str: LocalizedStringResource) {
168+
print(String(localized: str))
169+
}
170+
}
171+
172+
enum Errors: Swift.Error {
173+
case badResponse(_ response: URLResponse)
174+
case badStatus(response: URLResponse)
175+
case unknownAirportID(_ ID: String)
176+
case badMETAR(raw: String)
177+
}
178+
179+
extension Errors: LocalizedError {
180+
public var errorDescription: String? {
181+
switch self {
182+
case let .badResponse(response): "Bad response from AWC API: \(response)"
183+
case let .badStatus(response): "Bad status from AWC API: \(response)"
184+
case let .unknownAirportID(ID): "Airport ID “\(ID)” was not found"
185+
case let .badMETAR(raw): "Couldn’t parse METAR: \(raw)"
186+
}
187+
}
188+
189+
var failureReason: String? {
190+
switch self {
191+
case .badResponse, .badStatus: "The AWC API may have changed, or may not be functioning properly."
192+
case .unknownAirportID: "The airport is not in the AWC METAR database."
193+
case .badMETAR: "Either the METAR is incorrectly formatted, or SwiftMETAR should be fixed."
194+
}
195+
}
196+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Foundation
2+
import SwiftMETAR
3+
import METARFormatting
4+
import BuildableMacro
5+
6+
public extension RemarkEntry {
7+
8+
/// Formatter for `RemarkEntry`
9+
@Buildable struct FormatStyle: Foundation.FormatStyle, Sendable {
10+
11+
/// The format to use when printing times.
12+
public var dateFormat = Date.FormatStyle(date: .omitted, time: .shortened)
13+
14+
public func format(_ value: RemarkEntry) -> String {
15+
switch value.urgency {
16+
case .unknown: String(localized: "(?) \(value.remark, format: .remark(dateFormat: dateFormat))", comment: "unknown remark")
17+
case .routine: String(localized: "(R) \(value.remark, format: .remark(dateFormat: dateFormat))", comment: "routine remark")
18+
case .caution: String(localized: "(C) \(value.remark, format: .remark(dateFormat: dateFormat))", comment: "caution remark")
19+
case .urgent: String(localized: "(U) \(value.remark, format: .remark(dateFormat: dateFormat))", comment: "urgent remark")
20+
}
21+
}
22+
}
23+
}
24+
25+
public extension FormatStyle where Self == RemarkEntry.FormatStyle {
26+
static func remark(dateFormat: Date.FormatStyle? = nil) -> Self {
27+
if let dateFormat {
28+
.init(dateFormat: dateFormat)
29+
} else {
30+
.init()
31+
}
32+
}
33+
34+
static var remark: Self { .init() }
35+
}

0 commit comments

Comments
 (0)