timeprice 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/timeprice/cli.rb +55 -22
- data/lib/timeprice/errors.rb +5 -2
- data/lib/timeprice/exchange.rb +2 -0
- data/lib/timeprice/inflation.rb +17 -2
- data/lib/timeprice/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc65e20b859501552fbc5b53a7d6d3f833159d941ed5afc52bd1e9b51153d0fe
|
|
4
|
+
data.tar.gz: 93f795ee7466123a98dd56d6ef202d19de8e2e68ea0588cfcfd6a1172142d681
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d94e236e90f83d00a5e9ce871f74353aaf67245649ad7c3d21f69515b06049fecf4170844b4decea5533a3a82a9ceb444a5906f868535181ce0f13ce684119a5
|
|
7
|
+
data.tar.gz: f456c4a3b346031d6d69c2a55de77177022c9cabe7e1f656a8221dad767427786bc12d2838a81e67c514d6d9b86c76a33151f8f58095b672960f0f3b3f5c0bae
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.1.1] - 2026-05-11
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- CLI output formatting: currency-aware decimals (no `.0000` on JPY/VND), magnitude-aware FX rate precision (no `91.180000` for a 91.18 rate).
|
|
12
|
+
- `granularity` is omitted from human output when it's `monthly` (the happy path); surfaced only when the result used annual data.
|
|
13
|
+
- Error messages hint at supported values: `Unsupported country: "FR" (supported: US, UK, EU, JP, VN)`; out-of-range CPI errors include the actual coverage range.
|
|
14
|
+
- Validate FX currencies against the supported list up front instead of failing with a generic "no FX rate" message.
|
|
15
|
+
- Tightened CLI command descriptions so `timeprice help` fits in a standard terminal.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Hide Thor's built-in `tree` command — it was leaking into `timeprice help` as an internal-looking debug command.
|
|
19
|
+
|
|
8
20
|
## [0.1.0] - 2026-05-11
|
|
9
21
|
|
|
10
22
|
### Added
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -6,15 +6,23 @@ require_relative "../timeprice"
|
|
|
6
6
|
|
|
7
7
|
module Timeprice
|
|
8
8
|
class CLI < Thor
|
|
9
|
+
# Thor 1.5 ships a built-in `tree` command on every subclass. Strip it
|
|
10
|
+
# from this subclass — it's an internal debugging aid that leaks into
|
|
11
|
+
# our help output. all_commands inherits from the base Thor class, so
|
|
12
|
+
# filter it on read.
|
|
13
|
+
def self.all_commands
|
|
14
|
+
super.except("tree")
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
class_option :json, type: :boolean, default: false, desc: "Output result as JSON"
|
|
10
18
|
|
|
11
19
|
def self.exit_on_failure?
|
|
12
20
|
true
|
|
13
21
|
end
|
|
14
22
|
|
|
15
|
-
desc "inflation AMOUNT", "
|
|
16
|
-
method_option :from,
|
|
17
|
-
method_option :to,
|
|
23
|
+
desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
|
|
24
|
+
method_option :from, type: :string, required: true, desc: "Source date (YYYY or YYYY-MM)"
|
|
25
|
+
method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-MM)"
|
|
18
26
|
method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN)"
|
|
19
27
|
def inflation(amount)
|
|
20
28
|
with_error_handling do
|
|
@@ -28,7 +36,7 @@ module Timeprice
|
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
desc "fx AMOUNT
|
|
39
|
+
desc "fx AMOUNT FROM TO", "Convert an amount between currencies on a date"
|
|
32
40
|
method_option :date, type: :string, required: true, desc: "Date (YYYY-MM-DD)"
|
|
33
41
|
def fx(amount, from_currency, to_currency)
|
|
34
42
|
with_error_handling do
|
|
@@ -58,7 +66,7 @@ module Timeprice
|
|
|
58
66
|
end
|
|
59
67
|
end
|
|
60
68
|
|
|
61
|
-
desc "sources", "List bundled data sources
|
|
69
|
+
desc "sources", "List bundled data sources and coverage"
|
|
62
70
|
def sources
|
|
63
71
|
list = Timeprice::Sources.list
|
|
64
72
|
if options[:json]
|
|
@@ -86,19 +94,19 @@ module Timeprice
|
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
no_commands do
|
|
97
|
+
# Currencies with no minor unit — render whole numbers, no decimals.
|
|
98
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY VND KRW IDR HUF CLP].freeze
|
|
99
|
+
|
|
89
100
|
def with_error_handling
|
|
90
101
|
yield
|
|
91
102
|
rescue Timeprice::Error => e
|
|
92
103
|
warn "Error: #{e.message}"
|
|
93
104
|
exit 1
|
|
94
105
|
rescue ArgumentError => e
|
|
95
|
-
# Bad numeric/date format from Float() or library parsers — treat as user error.
|
|
96
106
|
warn "Error: #{e.message}"
|
|
97
107
|
exit 1
|
|
98
108
|
end
|
|
99
109
|
|
|
100
|
-
# Accepts "1995 USD" or "USD 1995" — order-agnostic.
|
|
101
|
-
# Returns [currency, year_string] tuple matching Timeprice.compare's API.
|
|
102
110
|
def parse_compare_token(token, label:)
|
|
103
111
|
raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
104
112
|
parts = token.strip.split(/\s+/)
|
|
@@ -115,15 +123,38 @@ module Timeprice
|
|
|
115
123
|
[currency.upcase, year]
|
|
116
124
|
end
|
|
117
125
|
|
|
126
|
+
def fmt_money(amount, currency)
|
|
127
|
+
decimals = ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
|
|
128
|
+
format("%.#{decimals}f", amount)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def fmt_rate(rate)
|
|
132
|
+
abs = rate.to_f.abs
|
|
133
|
+
decimals = if abs >= 1000 then 0
|
|
134
|
+
elsif abs >= 100 then 2
|
|
135
|
+
elsif abs >= 10 then 3
|
|
136
|
+
else 4
|
|
137
|
+
end
|
|
138
|
+
format("%.#{decimals}f", rate)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Granularity is loud noise on the happy path. Only surface it when the
|
|
142
|
+
# answer actually used annual data — that's where users want a heads-up.
|
|
143
|
+
def granularity_suffix(granularity)
|
|
144
|
+
return "" if granularity == :monthly
|
|
145
|
+
" (granularity: #{granularity})"
|
|
146
|
+
end
|
|
147
|
+
|
|
118
148
|
def emit_inflation(result)
|
|
119
149
|
if options[:json]
|
|
120
150
|
say JSON.generate(result.to_h)
|
|
121
151
|
else
|
|
152
|
+
ccy = result.country_currency_label
|
|
122
153
|
say format(
|
|
123
|
-
"
|
|
124
|
-
result.original_amount, result.
|
|
125
|
-
result.
|
|
126
|
-
result.
|
|
154
|
+
"%s %s in %s is %s %s in %s [%s]%s",
|
|
155
|
+
fmt_money(result.original_amount, ccy), ccy, result.from,
|
|
156
|
+
fmt_money(result.amount, ccy), ccy, result.to,
|
|
157
|
+
result.country, granularity_suffix(result.granularity)
|
|
127
158
|
)
|
|
128
159
|
end
|
|
129
160
|
end
|
|
@@ -133,12 +164,12 @@ module Timeprice
|
|
|
133
164
|
say JSON.generate(result.to_h)
|
|
134
165
|
else
|
|
135
166
|
line = format(
|
|
136
|
-
"
|
|
137
|
-
result.original_amount, result.from, result.date,
|
|
138
|
-
result.amount, result.to, result.rate
|
|
167
|
+
"%s %s on %s = %s %s (rate: %s)",
|
|
168
|
+
fmt_money(result.original_amount, result.from), result.from, result.date,
|
|
169
|
+
fmt_money(result.amount, result.to), result.to, fmt_rate(result.rate)
|
|
139
170
|
)
|
|
140
171
|
if result.effective_date && result.effective_date != result.date
|
|
141
|
-
line += "
|
|
172
|
+
line += " [effective: #{result.effective_date}, fallback]"
|
|
142
173
|
end
|
|
143
174
|
say line
|
|
144
175
|
end
|
|
@@ -149,14 +180,16 @@ module Timeprice
|
|
|
149
180
|
say JSON.generate(result.to_h)
|
|
150
181
|
else
|
|
151
182
|
say format(
|
|
152
|
-
"
|
|
153
|
-
result.original_amount, result.from_currency, result.from_date,
|
|
154
|
-
result.amount, result.to_currency, result.to_date
|
|
183
|
+
"%s %s in %s -> %s %s in %s",
|
|
184
|
+
fmt_money(result.original_amount, result.from_currency), result.from_currency, result.from_date,
|
|
185
|
+
fmt_money(result.amount, result.to_currency), result.to_currency, result.to_date
|
|
155
186
|
)
|
|
156
187
|
say format(
|
|
157
|
-
" steps:
|
|
158
|
-
result.
|
|
159
|
-
result.
|
|
188
|
+
" steps: %s %s -> %s %s (fx %s on %s), then inflate in %s x%.4f%s",
|
|
189
|
+
fmt_money(result.original_amount, result.from_currency), result.from_currency,
|
|
190
|
+
fmt_money(result.converted_amount, result.to_currency), result.to_currency,
|
|
191
|
+
fmt_rate(result.fx_rate), result.from_date,
|
|
192
|
+
result.country, result.cpi_ratio, granularity_suffix(result.granularity)
|
|
160
193
|
)
|
|
161
194
|
end
|
|
162
195
|
end
|
data/lib/timeprice/errors.rb
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
module Timeprice
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
+
SUPPORTED_COUNTRIES = %w[US UK EU JP VN].freeze
|
|
7
|
+
SUPPORTED_CURRENCIES = %w[USD GBP EUR JPY VND].freeze
|
|
8
|
+
|
|
6
9
|
class UnsupportedCountry < Error
|
|
7
10
|
attr_reader :country
|
|
8
11
|
|
|
9
12
|
def initialize(country)
|
|
10
13
|
@country = country
|
|
11
|
-
super("Unsupported country: #{country.inspect}")
|
|
14
|
+
super("Unsupported country: #{country.inspect} (supported: #{SUPPORTED_COUNTRIES.join(", ")})")
|
|
12
15
|
end
|
|
13
16
|
end
|
|
14
17
|
|
|
@@ -17,7 +20,7 @@ module Timeprice
|
|
|
17
20
|
|
|
18
21
|
def initialize(currency)
|
|
19
22
|
@currency = currency
|
|
20
|
-
super("Unsupported currency: #{currency.inspect}")
|
|
23
|
+
super("Unsupported currency: #{currency.inspect} (supported: #{SUPPORTED_CURRENCIES.join(", ")})")
|
|
21
24
|
end
|
|
22
25
|
end
|
|
23
26
|
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -20,6 +20,8 @@ module Timeprice
|
|
|
20
20
|
def convert(amount:, from:, to:, date:)
|
|
21
21
|
from = from.to_s.upcase
|
|
22
22
|
to = to.to_s.upcase
|
|
23
|
+
raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
|
|
24
|
+
raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
|
|
23
25
|
d = parse_date(date)
|
|
24
26
|
|
|
25
27
|
rate, eff_date = resolve_rate(from, to, d)
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -61,7 +61,7 @@ module Timeprice
|
|
|
61
61
|
if annual.key?(year)
|
|
62
62
|
[annual[year], :annual]
|
|
63
63
|
else
|
|
64
|
-
raise DataNotFound,
|
|
64
|
+
raise DataNotFound, missing_cpi_message(key, data, monthly, annual)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
when /\A\d{4}\z/
|
|
@@ -69,7 +69,7 @@ module Timeprice
|
|
|
69
69
|
[annual[key], :annual]
|
|
70
70
|
else
|
|
71
71
|
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
72
|
-
raise DataNotFound,
|
|
72
|
+
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
|
|
73
73
|
avg = months.values.sum.to_f / months.size
|
|
74
74
|
[avg, :annual_from_monthly_avg]
|
|
75
75
|
end
|
|
@@ -78,6 +78,21 @@ module Timeprice
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
def missing_cpi_message(key, data, monthly, annual)
|
|
82
|
+
country = data["country"]
|
|
83
|
+
ranges = []
|
|
84
|
+
if monthly.any?
|
|
85
|
+
ks = monthly.keys.sort
|
|
86
|
+
ranges << "monthly #{ks.first}..#{ks.last}"
|
|
87
|
+
end
|
|
88
|
+
if annual.any?
|
|
89
|
+
ks = annual.keys.sort
|
|
90
|
+
ranges << "annual #{ks.first}..#{ks.last}"
|
|
91
|
+
end
|
|
92
|
+
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
93
|
+
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
94
|
+
end
|
|
95
|
+
|
|
81
96
|
# If either end fell back to annual_from_monthly_avg, propagate that label;
|
|
82
97
|
# else if either is annual, propagate :annual; else :monthly.
|
|
83
98
|
def merge_granularity(a, b)
|
data/lib/timeprice/version.rb
CHANGED