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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b1b427948a0943958a53ce1820f72eb27b7dc41de61c9e216194aae9c03183d
4
- data.tar.gz: 5daac2f9428564420379cc33db7893a8b9dbf0f0344355c1439636e8abf647a7
3
+ metadata.gz: fc65e20b859501552fbc5b53a7d6d3f833159d941ed5afc52bd1e9b51153d0fe
4
+ data.tar.gz: 93f795ee7466123a98dd56d6ef202d19de8e2e68ea0588cfcfd6a1172142d681
5
5
  SHA512:
6
- metadata.gz: 14ec61a1a5cc71b33fb0de18bbbfc19ec152697d4f8ddcd2f14cf2d739e80571512ccb067d939f60207c0b2abc2971a03f32272cd2468d273782a1022d9d0dbb
7
- data.tar.gz: 5d7fae9eb6583d38f2245830aa66a0ce61fec10aa02cdec600005aea185635d0312c80a7d3bec62f0067a42651839a2af4de4d3c67335b1eb2a649dfe35b2419
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", "Adjust AMOUNT for inflation between two dates"
16
- method_option :from, type: :string, required: true, desc: "Source date (YYYY or YYYY-MM)"
17
- method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-MM)"
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 FROM_CURRENCY TO_CURRENCY", "Convert AMOUNT between currencies on a given date"
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, licenses, attribution, and coverage"
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
- "%.2f %s in %s is %.2f %s in %s (%s, granularity: %s)",
124
- result.original_amount, result.country_currency_label,
125
- result.from, result.amount, result.country_currency_label,
126
- result.to, result.country, result.granularity
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
- "%.2f %s on %s = %.2f %s (rate: %.4f)",
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 += " (effective date: #{result.effective_date} fallback)"
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
- "%.2f %s in %s -> %.2f %s in %s",
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: convert at %s (fx rate %.6f) -> %.4f %s, then inflate in %s (cpi ratio %.6f, granularity: %s)",
158
- result.from_date, result.fx_rate, result.converted_amount,
159
- result.to_currency, result.country, result.cpi_ratio, result.granularity
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
@@ -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
 
@@ -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)
@@ -61,7 +61,7 @@ module Timeprice
61
61
  if annual.key?(year)
62
62
  [annual[year], :annual]
63
63
  else
64
- raise DataNotFound, "No CPI data for #{key.inspect} in #{data["country"]}"
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, "No CPI data for #{key.inspect} in #{data["country"]}" if months.empty?
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timeprice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick