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 +4 -4
- data/Changelog +4 -0
- data/README.md +3 -0
- data/lib/worldbank_as_dataframe/commodities.rb +85 -16
- data/lib/worldbank_as_dataframe/version.rb +1 -1
- data/reqs/executed_plans.md +3 -0
- data/reqs/explicitly_stated_requirements.md +5 -0
- data/reqs/requirements/REQ-001.md +13 -0
- data/spec/fixtures/cmo_monthly_prices.xlsx +0 -0
- data/spec/worldbank_as_dataframe/commodities_spec.rb +29 -0
- data/test/test_commodities.rb +31 -0
- data/worldbank_as_dataframe.gemspec +1 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dcb1ea76123deedc99ddce0e174ccd7930eba3a27ed76a617c7c8a7b28eb967a
|
|
4
|
+
data.tar.gz: daa92655da05f0df4014a67f4c3d35c14eff14c319c4da619747312c79a6b6d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c08b5c1fda2faaef793185a0c8b3e1c56509d98ca38380faf892952476fb42a75da6b8c0d1be99cfeab4192c95f3803762cc048c4e5d040987672b9ce36e348c
|
|
7
|
+
data.tar.gz: f73a4ccd0590c351e8fde7e932b2744ec818034e7340eae868f3b79e8a89b1aae82bb746c9698c8ce542abfe6422607b3c9acd5f5755f8e4f16e267224a479a1
|
data/Changelog
CHANGED
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 '
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
df = df.filter(Polars.col('Timestamps')
|
|
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
|
-
|
|
33
|
-
df = df.select(['Timestamps',
|
|
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
|
|
@@ -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.
|
|
Binary file
|
|
@@ -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
|
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.
|
|
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:
|
|
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
|