timeprice 0.1.1 → 0.1.2

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: fc65e20b859501552fbc5b53a7d6d3f833159d941ed5afc52bd1e9b51153d0fe
4
- data.tar.gz: 93f795ee7466123a98dd56d6ef202d19de8e2e68ea0588cfcfd6a1172142d681
3
+ metadata.gz: b0ebaf1340f0abefa6b4dcae31f7d2f2d22021155d24ebc258f15b0495f7a480
4
+ data.tar.gz: 6068762094c6008c72f4dd7becb10e1e8cb0d0c17b668234e8e0dff84c494cc2
5
5
  SHA512:
6
- metadata.gz: d94e236e90f83d00a5e9ce871f74353aaf67245649ad7c3d21f69515b06049fecf4170844b4decea5533a3a82a9ceb444a5906f868535181ce0f13ce684119a5
7
- data.tar.gz: f456c4a3b346031d6d69c2a55de77177022c9cabe7e1f656a8221dad767427786bc12d2838a81e67c514d6d9b86c76a33151f8f58095b672960f0f3b3f5c0bae
6
+ metadata.gz: fa08a556599384d4ae292064b2342bfdc6976eac3fea784afb7426d623fdd0c8ee43001135bd2f7c3a065e735db8dd6d347459144e44ee702f30505ee23c3c1f
7
+ data.tar.gz: 9fd49be55c8790b4a1adfd9350fdd2e6eed82cdcbf8e41c2dd3b1e1514e5b81f9ffa71dc0a9b530738ac2f2d6fa22e969c240bd81dac043e4a4b8e23b21b6924
data/CHANGELOG.md CHANGED
@@ -5,7 +5,19 @@ 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
8
+ ## [0.1.2] - 2026-05-11
9
+
10
+ ### Added
11
+ - RuboCop with `rubocop-rake` + `rubocop-rspec`, wired into Rake (`rake default` runs spec + rubocop) and CI (separate `RuboCop` job alongside `RSpec`).
12
+
13
+ ### Changed
14
+ - `DataLoader.load_cpi` now distinguishes between "country isn't supported" (`UnsupportedCountry`) and "data file is missing on disk" (`DataNotFound` with the path the loader looked at). Previously both surfaced as `UnsupportedCountry`, masking install / `TIMEPRICE_DATA_ROOT` misconfigurations.
15
+ - `Timeprice.exchange` now rejects invalid calendar dates (e.g. `2021-02-29`) with `ArgumentError` instead of leaking a `Date::Error`. Honors the public error contract.
16
+ - Trimmed `ZERO_DECIMAL_CURRENCIES` to currencies actually supported by the gem (JPY, VND). Removed aspirational entries (KRW, IDR, HUF, CLP).
17
+ - Inline source comments now reference README sections (`README.md "Compare semantics"`) instead of `PLAN.md` (which is intentionally not shipped in the gem).
18
+ - `CONTRIBUTING.md` updated to match the single-Ruby CI; both `rspec` and `rubocop` must be green.
19
+
20
+
9
21
 
10
22
  ### Changed
11
23
  - CLI output formatting: currency-aware decimals (no `.0000` on JPY/VND), magnitude-aware FX rate precision (no `91.180000` for a 91.18 rate).
data/lib/timeprice/cli.rb CHANGED
@@ -73,7 +73,7 @@ module Timeprice
73
73
  say JSON.generate(list)
74
74
  else
75
75
  list.each do |s|
76
- say "#{s[:name]}"
76
+ say s[:name].to_s
77
77
  say " id: #{s[:id]}"
78
78
  say " license: #{s[:license]}"
79
79
  say " license_url: #{s[:license_url]}"
@@ -95,7 +95,7 @@ module Timeprice
95
95
 
96
96
  no_commands do
97
97
  # Currencies with no minor unit — render whole numbers, no decimals.
98
- ZERO_DECIMAL_CURRENCIES = %w[JPY VND KRW IDR HUF CLP].freeze
98
+ ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
99
99
 
100
100
  def with_error_handling
101
101
  yield
@@ -109,6 +109,7 @@ module Timeprice
109
109
 
110
110
  def parse_compare_token(token, label:)
111
111
  raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
112
+
112
113
  parts = token.strip.split(/\s+/)
113
114
  unless parts.size == 2
114
115
  raise ArgumentError,
@@ -142,6 +143,7 @@ module Timeprice
142
143
  # answer actually used annual data — that's where users want a heads-up.
143
144
  def granularity_suffix(granularity)
144
145
  return "" if granularity == :monthly
146
+
145
147
  " (granularity: #{granularity})"
146
148
  end
147
149
 
@@ -16,7 +16,7 @@ module Timeprice
16
16
  # Compare combines FX and inflation across two (currency, date) points.
17
17
  #
18
18
  # CONVENTION (critical): convert at SOURCE date first, then inflate in
19
- # destination currency. See PLAN.md §2 last bullet and §7.
19
+ # destination currency. See README.md "Compare semantics" section.
20
20
  #
21
21
  # This preserves purchasing-power equivalence in the destination economy.
22
22
  # The naive alternative (inflate in source currency first, then convert at
@@ -32,7 +32,7 @@ module Timeprice
32
32
  "GBP" => "UK",
33
33
  "EUR" => "EU",
34
34
  "JPY" => "JP",
35
- "VND" => "VN"
35
+ "VND" => "VN",
36
36
  }.freeze
37
37
 
38
38
  module_function
@@ -78,7 +78,7 @@ module Timeprice
78
78
  to_date: to_date.to_s,
79
79
  country: to_country,
80
80
  fx_rate: fx_result.rate,
81
- cpi_ratio: infl.to_index.to_f / infl.from_index.to_f,
81
+ cpi_ratio: infl.to_index.to_f / infl.from_index,
82
82
  converted_amount: converted,
83
83
  granularity: infl.granularity
84
84
  )
@@ -27,9 +27,16 @@ module Timeprice
27
27
  def load_cpi(country)
28
28
  @cpi_cache ||= {}
29
29
  key = country.to_s.downcase
30
+ code = country.to_s.upcase
30
31
  @cpi_cache[[data_root, key]] ||= begin
32
+ raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
33
+
31
34
  path = File.join(data_root, "cpi", "#{key}.json")
32
- raise UnsupportedCountry, country.to_s.upcase unless File.exist?(path)
35
+ unless File.exist?(path)
36
+ raise DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
37
+ "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
38
+ end
39
+
33
40
  parse_with_schema(path)
34
41
  end
35
42
  end
@@ -40,6 +47,7 @@ module Timeprice
40
47
  @fx_cache[[data_root, key]] ||= begin
41
48
  path = File.join(data_root, "fx", "usd", "#{key}.json")
42
49
  raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
50
+
43
51
  parse_with_schema(path)
44
52
  end
45
53
  end
@@ -49,9 +57,8 @@ module Timeprice
49
57
  def parse_with_schema(path)
50
58
  data = JSON.parse(File.read(path))
51
59
  version = data["schema_version"]
52
- unless version == SUPPORTED_SCHEMA_VERSION
53
- raise UnsupportedSchemaVersion.new(version, path)
54
- end
60
+ raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
61
+
55
62
  data
56
63
  end
57
64
  end
@@ -22,6 +22,7 @@ module Timeprice
22
22
  to = to.to_s.upcase
23
23
  raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
24
24
  raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
25
+
25
26
  d = parse_date(date)
26
27
 
27
28
  rate, eff_date = resolve_rate(from, to, d)
@@ -77,8 +78,10 @@ module Timeprice
77
78
  end
78
79
  rates_for_day = year_data.dig("rates", candidate.to_s)
79
80
  next unless rates_for_day
81
+
80
82
  rate = rates_for_day[currency]
81
83
  next unless rate
84
+
82
85
  return [rate.to_f, candidate]
83
86
  end
84
87
  raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
@@ -91,7 +94,12 @@ module Timeprice
91
94
  unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
92
95
  raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
93
96
  end
94
- Date.parse(date)
97
+
98
+ begin
99
+ Date.parse(date)
100
+ rescue Date::Error
101
+ raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
102
+ end
95
103
  else
96
104
  raise ArgumentError, "Invalid date: #{date.inspect}"
97
105
  end
@@ -27,7 +27,7 @@ module Timeprice
27
27
  from_index, from_gran = lookup_index(data, from)
28
28
  to_index, to_gran = lookup_index(data, to)
29
29
 
30
- ratio = to_index.to_f / from_index.to_f
30
+ ratio = to_index.to_f / from_index
31
31
  InflationResult.new(
32
32
  amount: amount.to_f * ratio,
33
33
  original_amount: amount.to_f,
@@ -58,11 +58,10 @@ module Timeprice
58
58
  [monthly[key], :monthly]
59
59
  else
60
60
  year = key[0, 4]
61
- if annual.key?(year)
62
- [annual[year], :annual]
63
- else
64
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual)
65
- end
61
+ raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
62
+
63
+ [annual[year], :annual]
64
+
66
65
  end
67
66
  when /\A\d{4}\z/
68
67
  if annual.key?(key)
@@ -70,6 +69,7 @@ module Timeprice
70
69
  else
71
70
  months = monthly.select { |k, _| k.start_with?("#{key}-") }
72
71
  raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
72
+
73
73
  avg = months.values.sum.to_f / months.size
74
74
  [avg, :annual_from_monthly_avg]
75
75
  end
@@ -98,6 +98,7 @@ module Timeprice
98
98
  def merge_granularity(a, b)
99
99
  return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
100
100
  return :annual if a == :annual || b == :annual
101
+
101
102
  :monthly
102
103
  end
103
104
  end
@@ -15,7 +15,7 @@ module Timeprice
15
15
  name: "U.S. Bureau of Labor Statistics — CPI-U (series CUUR0000SA0)",
16
16
  license: "U.S. Government work — public domain",
17
17
  license_url: "https://www.bls.gov/bls/linksite.htm",
18
- attribution: "Data: U.S. Bureau of Labor Statistics"
18
+ attribution: "Data: U.S. Bureau of Labor Statistics",
19
19
  },
20
20
  {
21
21
  id: "uk_cpi",
@@ -24,7 +24,7 @@ module Timeprice
24
24
  name: "UK Office for National Statistics — CPI all-items (series D7BT)",
25
25
  license: "Open Government Licence v3.0",
26
26
  license_url: "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/",
27
- attribution: "Contains public sector information licensed under the Open Government Licence v3.0"
27
+ attribution: "Contains public sector information licensed under the Open Government Licence v3.0",
28
28
  },
29
29
  {
30
30
  id: "eu_hicp",
@@ -33,7 +33,7 @@ module Timeprice
33
33
  name: "Eurostat — HICP prc_hicp_midx (Euro area, all items)",
34
34
  license: "Eurostat reuse policy (free reuse with attribution)",
35
35
  license_url: "https://ec.europa.eu/eurostat/about-us/policies/copyright",
36
- attribution: "Source: Eurostat"
36
+ attribution: "Source: Eurostat",
37
37
  },
38
38
  {
39
39
  id: "jp_cpi",
@@ -42,7 +42,7 @@ module Timeprice
42
42
  name: "World Bank — FP.CPI.TOTL (annual, JP fallback)",
43
43
  license: "CC BY 4.0",
44
44
  license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
45
- attribution: "Source: World Bank, FP.CPI.TOTL"
45
+ attribution: "Source: World Bank, FP.CPI.TOTL",
46
46
  },
47
47
  {
48
48
  id: "vn_cpi",
@@ -51,7 +51,7 @@ module Timeprice
51
51
  name: "World Bank — FP.CPI.TOTL (annual)",
52
52
  license: "CC BY 4.0",
53
53
  license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
54
- attribution: "Source: World Bank, FP.CPI.TOTL"
54
+ attribution: "Source: World Bank, FP.CPI.TOTL",
55
55
  },
56
56
  {
57
57
  id: "fx_ecb",
@@ -60,7 +60,7 @@ module Timeprice
60
60
  name: "European Central Bank reference rates (via Frankfurter)",
61
61
  license: "ECB reference rates — free reuse",
62
62
  license_url: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html",
63
- attribution: "FX data: European Central Bank reference rates via Frankfurter"
63
+ attribution: "FX data: European Central Bank reference rates via Frankfurter",
64
64
  },
65
65
  {
66
66
  id: "fx_vnd",
@@ -69,8 +69,8 @@ module Timeprice
69
69
  name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
70
70
  license: "CC BY 4.0",
71
71
  license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
72
- attribution: "VND FX: World Bank, PA.NUS.FCRF"
73
- }
72
+ attribution: "VND FX: World Bank, PA.NUS.FCRF",
73
+ },
74
74
  ].freeze
75
75
 
76
76
  module_function
@@ -105,6 +105,7 @@ module Timeprice
105
105
  root = File.join(DataLoader.data_root, "fx", "usd")
106
106
  years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
107
107
  return "no data" if years.empty?
108
+
108
109
  case id
109
110
  when "fx_vnd"
110
111
  # VND broadcast-from-annual covers earlier years too.
@@ -113,14 +114,16 @@ module Timeprice
113
114
  d["rates"].any? { |_, v| v.key?("VND") }
114
115
  end
115
116
  return "no VND data" if with_vnd.empty?
117
+
116
118
  "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
117
119
  else
118
120
  # ECB pairs (EUR/GBP/JPY) start 1999
119
121
  ecb_years = years.select do |y|
120
122
  d = JSON.parse(File.read(File.join(root, "#{y}.json")))
121
- d["rates"].any? { |_, v| (v.keys & %w[EUR GBP JPY]).any? }
123
+ d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
122
124
  end
123
125
  return "no ECB data" if ecb_years.empty?
126
+
124
127
  "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
125
128
  end
126
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -51,6 +51,48 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '3.13'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.69'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.69'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.6'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.6'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.3'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.3'
54
96
  description: Offline historical inflation & FX for Ruby - bundled data, no API keys,
55
97
  monthly auto-refresh.
56
98
  email:
@@ -130,6 +172,7 @@ metadata:
130
172
  bug_tracker_uri: https://github.com/patrick204nqh/timeprice/issues
131
173
  changelog_uri: https://github.com/patrick204nqh/timeprice/blob/main/CHANGELOG.md
132
174
  github_repo: patrick204nqh/timeprice
175
+ rubygems_mfa_required: 'true'
133
176
  rdoc_options: []
134
177
  require_paths:
135
178
  - lib