worldbank_as_dataframe 0.2.1 → 0.2.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: a1141c280f36ed7062973307d4020dce4dc9800a942461b9f9333d68599fd1a4
4
- data.tar.gz: f506f93a82cc9d85e00ec518e6a1cf88f6ce5dba88fd9c409e28db7ddfbe5c79
3
+ metadata.gz: dcb1ea76123deedc99ddce0e174ccd7930eba3a27ed76a617c7c8a7b28eb967a
4
+ data.tar.gz: daa92655da05f0df4014a67f4c3d35c14eff14c319c4da619747312c79a6b6d9
5
5
  SHA512:
6
- metadata.gz: de67152e06b713eff2488cead10307c8b19fb19da27912bae2236849a7c6a43ab8fb1ed729d57a45b578589c9b4f7cb7f7d5aff07bf5acebebed6a245df98073
7
- data.tar.gz: b5fc9be7e5419a206b9cfd99202eb0f96b8ea6661285a35ca47aec7c552e98e0080dd586fdb76ba2d45668ca9c22b86bebf3025383ee7815c8b61e5cce720080
6
+ metadata.gz: c08b5c1fda2faaef793185a0c8b3e1c56509d98ca38380faf892952476fb42a75da6b8c0d1be99cfeab4192c95f3803762cc048c4e5d040987672b9ce36e348c
7
+ data.tar.gz: f73a4ccd0590c351e8fde7e932b2744ec818034e7340eae868f3b79e8a89b1aae82bb746c9698c8ce542abfe6422607b3c9acd5f5755f8e4f16e267224a479a1
data/Changelog CHANGED
@@ -1,3 +1,7 @@
1
+ 0.2.2
2
+ Parse CMO commodity workbooks with Roo instead of SimpleXlsxReader
3
+ Match the Monthly Prices sheet after normalizing title whitespace and case
4
+ Skip chart-only sheets such as AFOSHEET
1
5
  0.0.1
2
6
  Initial release
3
7
  0.9.0
data/README.md CHANGED
@@ -96,6 +96,9 @@ include WorldbankAsDataframe
96
96
  # modifiers (like #dates above) called as class methods or chained in a query.
97
97
  #
98
98
 
99
+ # Commodities#fetch downloads the Pink Sheet xlsx and parses the Monthly Prices
100
+ # sheet with Roo (chart-only sheets such as AFOSHEET are skipped).
101
+
99
102
  WorldbankAsDataframe::Commodities.new.fetch
100
103
  # =>
101
104
  # shape: (777, 72)
@@ -1,39 +1,108 @@
1
1
  require 'open-uri'
2
- require 'simple_xlsx_reader'
2
+ require 'roo'
3
3
  require 'polars-df'
4
+ require 'tempfile'
5
+ require 'date'
4
6
 
5
7
  module WorldbankAsDataframe
6
8
  class Commodities
9
+ CMO_URL = 'https://thedocs.worldbank.org/en/doc/5d903e848db1d1b83e0ec8f744e55570-0350012021/related/CMO-Historical-Data-Monthly.xlsx'
10
+ MONTHLY_PRICES_SHEET = 'Monthly Prices'
11
+ MISSING_MARKERS = ["…", "..."].freeze
12
+
7
13
  attr_reader :tag
8
14
 
9
15
  def initialize(series = nil, options={})
10
16
  @tag = series
11
17
  end
12
18
 
13
- def fetch(start: nil, fin: nil)
14
- doc = SimpleXlsxReader.parse(URI.open('https://thedocs.worldbank.org/en/doc/5d903e848db1d1b83e0ec8f744e55570-0350012021/related/CMO-Historical-Data-Monthly.xlsx'))
15
- ary = doc.sheets.detect{|sht| sht.name == 'Monthly Prices'}.rows.to_a[4..-1]
19
+ def fetch(start: nil, fin: nil, path: nil)
20
+ if path
21
+ _frame_from_path(path, start: start, fin: fin)
22
+ else
23
+ Tempfile.open(['cmo', '.xlsx'], binmode: true) do |f|
24
+ f.write(URI.open(CMO_URL, 'User-Agent' => 'worldbank_as_dataframe').read)
25
+ f.flush
26
+ _frame_from_path(f.path, start: start, fin: fin)
27
+ end
28
+ end
29
+ end
30
+
31
+ def _frame_from_path(path, start: nil, fin: nil)
32
+ xlsx = Roo::Excelx.new(path)
33
+ rows = _monthly_price_rows(xlsx)
34
+ _build_frame(rows, start: start, fin: fin)
35
+ end
36
+
37
+ def _monthly_price_rows(xlsx)
38
+ name = xlsx.sheets.find { |sheet_name| _normalize_key(sheet_name) == _normalize_key(MONTHLY_PRICES_SHEET) }
39
+ raise 'CMO workbook contained no Monthly Prices sheet' if name.nil?
40
+
41
+ sheet = xlsx.sheet(name)
42
+ last_row = sheet.last_row
43
+ raise 'CMO Monthly Prices sheet is empty' if last_row.nil?
44
+
45
+ (1..last_row).map { |idx| sheet.row(idx) }
46
+ end
47
+
48
+ def _build_frame(rows, start: nil, fin: nil)
49
+ ary = rows[4..].map { |row| row.dup }
50
+ raise 'CMO Monthly Prices sheet is missing header rows' if ary.nil? || ary.length < 3
51
+
16
52
  ary[0][0] = 'Timestamps'
17
- ary.map!{|a| a.reverse.drop_while(&:nil?).reverse }
18
- ary[1].length.times {|i| ary[1][i] = [ary[0][i], ary[1][i]].compact.join(' ') }
19
- ary = ary[1..-1]
53
+ ary.map! { |a| a.reverse.drop_while(&:nil?).reverse }
54
+ ary[1].length.times { |i| ary[1][i] = [ary[0][i], ary[1][i]].compact.join(' ') }
55
+ ary = ary[1..]
20
56
 
21
- dat = ary[1..-1]
22
57
  cols = ary[0]
58
+ dat = ary[1..].map { |row| _coerce_row(row) }
59
+ dat.reject! { |row| row.compact.empty? }
23
60
 
24
- dat.each{|a| dt = a[0].split('M'); a[0] = Date.new(dt[0].to_i, dt[1].to_i, 1).to_date }
25
- dat.each{|row| row.map!{|c| c.in?(["…","..."]) ? nil : c }}
61
+ frame_hsh = {}
62
+ cols.each_with_index { |col, i| frame_hsh[col] = dat.map { |row| row[i] } }
63
+ df = Polars::DataFrame.new(frame_hsh)
26
64
 
27
- df = Polars::DataFrame.new(dat.transpose, columns: cols)
28
- df = df.filter(Polars.col('Timestamps') >= start.to_date) unless start.nil?
29
- df = df.filter(Polars.col('Timestamps') <= fin.to_date) unless fin.nil?
65
+ start_date = _to_date(start)
66
+ fin_date = _to_date(fin)
67
+ df = df.filter(Polars.col('Timestamps') >= start_date) unless start_date.nil?
68
+ df = df.filter(Polars.col('Timestamps') <= fin_date) unless fin_date.nil?
30
69
 
31
70
  unless @tag.nil?
32
- cols = cols.map{|c| (/#{tag.downcase}/ =~ c.downcase).nil? ? nil : c }.compact
33
- df = df.select(['Timestamps', cols].flatten) if (cols.length>0)
71
+ matched = cols.select { |c| c.to_s.downcase.include?(@tag.to_s.downcase) }
72
+ df = df.select(['Timestamps', *matched]) if matched.length > 0
34
73
  end
35
74
 
36
75
  df
37
76
  end
77
+
78
+ def _normalize_label(text)
79
+ return '' if text.nil?
80
+
81
+ text.to_s
82
+ .gsub(/<[^>]+>/, '')
83
+ .gsub(/[[:space:]]+/, ' ')
84
+ .delete('*')
85
+ .strip
86
+ end
87
+
88
+ def _normalize_key(text)
89
+ _normalize_label(text).downcase
90
+ end
91
+
92
+ def _coerce_row(row)
93
+ row = row.dup
94
+ if row[0].to_s =~ /\A(\d{4})M(\d{2})\z/
95
+ row[0] = Date.new($1.to_i, $2.to_i, 1)
96
+ end
97
+ row.map { |cell| MISSING_MARKERS.include?(cell) ? nil : cell }
98
+ end
99
+
100
+ def _to_date(value)
101
+ return nil if value.nil?
102
+ return value if value.is_a?(Date)
103
+ return value.to_date if value.respond_to?(:to_date)
104
+
105
+ Date.parse(value.to_s)
106
+ end
38
107
  end
39
- end
108
+ end
@@ -1,3 +1,3 @@
1
1
  module WorldbankAsDataframe
2
- VERSION = "0.2.1"
2
+ VERSION = "0.2.2"
3
3
  end
@@ -0,0 +1,3 @@
1
+ # Executed plans
2
+
3
+ No executed-plan notes have been recorded yet.
@@ -0,0 +1,5 @@
1
+ # Explicitly stated requirements
2
+
3
+ | ID | Title | File |
4
+ | --- | --- | --- |
5
+ | REQ-001 | Parse the current World Bank CMO workbook | [requirements/REQ-001.md](requirements/REQ-001.md) |
@@ -0,0 +1,13 @@
1
+ # REQ-001 — Parse the current World Bank CMO workbook
2
+
3
+ **Source:** user directive to fix the same SimpleXlsxReader failures found in `ny_frb_as_dataframe` (2026-09-09).
4
+
5
+ ## Statement
6
+
7
+ `WorldbankAsDataframe::Commodities#fetch` must load the current Pink Sheet `CMO-Historical-Data-Monthly.xlsx` and return a Polars frame.
8
+
9
+ ## Required behavior
10
+
11
+ - Do not use SimpleXlsxReader. The published workbook includes a chart sheet (`AFOSHEET`) and `simple_xlsx_reader` 1.0.5 fails on sheets without a dimension node. Parse data sheets with Roo.
12
+ - Do not set `SimpleXlsxReader.configuration.auto_slurp`.
13
+ - Match the `Monthly Prices` sheet after normalizing whitespace and case.
@@ -0,0 +1,29 @@
1
+ require 'helper'
2
+
3
+ describe WorldbankAsDataframe::Commodities do
4
+ let(:fixture) { File.expand_path('../../fixtures/cmo_monthly_prices.xlsx', __FILE__) }
5
+
6
+ it 'is instantiable' do
7
+ WorldbankAsDataframe::Commodities.new.should be_a WorldbankAsDataframe::Commodities
8
+ end
9
+
10
+ it 'parses Monthly Prices from a chart-heavy workbook via Roo' do
11
+ df = WorldbankAsDataframe::Commodities.new.fetch(path: fixture)
12
+ df.columns.should include('Timestamps')
13
+ df.columns.should include('Crude oil, average ($/bbl)')
14
+ df['Timestamps'].to_a.should include(Date.new(1960, 1, 1))
15
+ df['Crude oil, average ($/bbl)'].to_a.first.should == 1.63
16
+ end
17
+
18
+ it 'treats ellipsis cells as null and matches series tags case-insensitively' do
19
+ df = WorldbankAsDataframe::Commodities.new('crude oil').fetch(path: fixture)
20
+ df.columns.should == ['Timestamps', 'Crude oil, average ($/bbl)']
21
+ df.filter(Polars.col('Timestamps') == Date.new(1960, 2, 1))['Crude oil, average ($/bbl)'].to_a.first.should be_nil
22
+ end
23
+
24
+ it 'filters by start and fin' do
25
+ df = WorldbankAsDataframe::Commodities.new.fetch(path: fixture, start: '2020-01-01', fin: '2020-12-31')
26
+ df.height.should == 1
27
+ df['Timestamps'].to_a.should == [Date.new(2020, 1, 1)]
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'date'
5
+ require_relative '../lib/worldbank_as_dataframe'
6
+
7
+ FIXTURE = File.expand_path('../spec/fixtures/cmo_monthly_prices.xlsx', __dir__)
8
+ failures = []
9
+
10
+ failures << 'version' if WorldbankAsDataframe::VERSION.to_s.empty?
11
+
12
+ df = WorldbankAsDataframe::Commodities.new.fetch(path: FIXTURE)
13
+ failures << 'timestamps column' unless df.columns.include?('Timestamps')
14
+ failures << 'crude column' unless df.columns.include?('Crude oil, average ($/bbl)')
15
+ failures << '1960-01-01 row' unless df['Timestamps'].to_a.include?(Date.new(1960, 1, 1))
16
+ failures << 'crude value' unless df['Crude oil, average ($/bbl)'].to_a.first == 1.63
17
+
18
+ tagged = WorldbankAsDataframe::Commodities.new('CRUDE OIL').fetch(path: FIXTURE)
19
+ failures << 'tag columns' unless tagged.columns == ['Timestamps', 'Crude oil, average ($/bbl)']
20
+
21
+ windowed = WorldbankAsDataframe::Commodities.new.fetch(path: FIXTURE, start: '2020-01-01', fin: '2020-12-31')
22
+ failures << 'date window' unless windowed.height == 1 && windowed['Timestamps'].to_a == [Date.new(2020, 1, 1)]
23
+
24
+ c = WorldbankAsDataframe::Commodities.new
25
+ failures << 'normalize' unless c._normalize_key('<html>Monthly Prices*</html>') == 'monthly prices'
26
+
27
+ if failures.empty?
28
+ puts 'test_commodities: ok'
29
+ else
30
+ abort "test_commodities failed: #{failures.join(', ')}"
31
+ end
@@ -26,5 +26,6 @@ Gem::Specification.new do |gem|
26
26
  gem.add_runtime_dependency 'httparty'
27
27
  gem.add_runtime_dependency 'polars-df'
28
28
  gem.add_runtime_dependency 'multi_json'
29
+ gem.add_runtime_dependency 'roo'
29
30
  # gem.add_runtime_dependency 'rash', '~> 0.3.0'
30
31
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: worldbank_as_dataframe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bill McKinnon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-10-10 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ZenTest
@@ -150,6 +150,20 @@ dependencies:
150
150
  - - ">="
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: roo
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :runtime
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
153
167
  description: A Ruby wrapper around the World Bank's Development Indicators API, with
154
168
  credit to Code for America and Justin Stoller
155
169
  email:
@@ -191,7 +205,11 @@ files:
191
205
  - lib/worldbank_as_dataframe/source.rb
192
206
  - lib/worldbank_as_dataframe/topic.rb
193
207
  - lib/worldbank_as_dataframe/version.rb
208
+ - reqs/executed_plans.md
209
+ - reqs/explicitly_stated_requirements.md
210
+ - reqs/requirements/REQ-001.md
194
211
  - spec/fixtures/brazil.json
212
+ - spec/fixtures/cmo_monthly_prices.xlsx
195
213
  - spec/fixtures/countries.json
196
214
  - spec/fixtures/countries_india.json
197
215
  - spec/fixtures/income_level_lmc.json
@@ -208,6 +226,7 @@ files:
208
226
  - spec/fixtures/topics.json
209
227
  - spec/helper.rb
210
228
  - spec/worldbank_as_dataframe/client_spec.rb
229
+ - spec/worldbank_as_dataframe/commodities_spec.rb
211
230
  - spec/worldbank_as_dataframe/country_spec.rb
212
231
  - spec/worldbank_as_dataframe/data_query_spec.rb
213
232
  - spec/worldbank_as_dataframe/data_spec.rb
@@ -220,6 +239,7 @@ files:
220
239
  - spec/worldbank_as_dataframe/source_spec.rb
221
240
  - spec/worldbank_as_dataframe/topic_spec.rb
222
241
  - spec/worldbank_as_dataframe_spec.rb
242
+ - test/test_commodities.rb
223
243
  - worldbank_as_dataframe.gemspec
224
244
  homepage: https://github.com/bmck/worldbank_as_dataframe
225
245
  licenses: []
@@ -245,6 +265,7 @@ specification_version: 4
245
265
  summary: A Ruby wrapper around the World Bank's Development Indicators API
246
266
  test_files:
247
267
  - spec/fixtures/brazil.json
268
+ - spec/fixtures/cmo_monthly_prices.xlsx
248
269
  - spec/fixtures/countries.json
249
270
  - spec/fixtures/countries_india.json
250
271
  - spec/fixtures/income_level_lmc.json
@@ -261,6 +282,7 @@ test_files:
261
282
  - spec/fixtures/topics.json
262
283
  - spec/helper.rb
263
284
  - spec/worldbank_as_dataframe/client_spec.rb
285
+ - spec/worldbank_as_dataframe/commodities_spec.rb
264
286
  - spec/worldbank_as_dataframe/country_spec.rb
265
287
  - spec/worldbank_as_dataframe/data_query_spec.rb
266
288
  - spec/worldbank_as_dataframe/data_spec.rb
@@ -273,3 +295,4 @@ test_files:
273
295
  - spec/worldbank_as_dataframe/source_spec.rb
274
296
  - spec/worldbank_as_dataframe/topic_spec.rb
275
297
  - spec/worldbank_as_dataframe_spec.rb
298
+ - test/test_commodities.rb