development-finance 0.1.0__tar.gz

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.
Files changed (84) hide show
  1. development_finance-0.1.0/PKG-INFO +307 -0
  2. development_finance-0.1.0/README.md +278 -0
  3. development_finance-0.1.0/pyproject.toml +184 -0
  4. development_finance-0.1.0/pyproject.toml.orig +198 -0
  5. development_finance-0.1.0/src/dfo/__init__.py +65 -0
  6. development_finance-0.1.0/src/dfo/_client/__init__.py +1 -0
  7. development_finance-0.1.0/src/dfo/_client/analysis.py +234 -0
  8. development_finance-0.1.0/src/dfo/_client/cards.py +94 -0
  9. development_finance-0.1.0/src/dfo/_client/cli.py +160 -0
  10. development_finance-0.1.0/src/dfo/_client/client.py +499 -0
  11. development_finance-0.1.0/src/dfo/_client/default.py +348 -0
  12. development_finance-0.1.0/src/dfo/_config/__init__.py +1 -0
  13. development_finance-0.1.0/src/dfo/_config/credentials.py +145 -0
  14. development_finance-0.1.0/src/dfo/_config/keys.py +344 -0
  15. development_finance-0.1.0/src/dfo/_config/paths.py +119 -0
  16. development_finance-0.1.0/src/dfo/_core/__init__.py +1 -0
  17. development_finance-0.1.0/src/dfo/_core/codes.py +78 -0
  18. development_finance-0.1.0/src/dfo/_core/errors.py +446 -0
  19. development_finance-0.1.0/src/dfo/_core/json_text.py +66 -0
  20. development_finance-0.1.0/src/dfo/_core/lookup_results.py +319 -0
  21. development_finance-0.1.0/src/dfo/_core/nearest.py +45 -0
  22. development_finance-0.1.0/src/dfo/_core/parse.py +509 -0
  23. development_finance-0.1.0/src/dfo/_core/periods.py +79 -0
  24. development_finance-0.1.0/src/dfo/_core/refs.py +787 -0
  25. development_finance-0.1.0/src/dfo/_core/refusals.py +337 -0
  26. development_finance-0.1.0/src/dfo/_core/text.py +323 -0
  27. development_finance-0.1.0/src/dfo/_core/types.py +791 -0
  28. development_finance-0.1.0/src/dfo/_datasets/__init__.py +1 -0
  29. development_finance-0.1.0/src/dfo/_datasets/analysis.py +660 -0
  30. development_finance-0.1.0/src/dfo/_datasets/build.py +753 -0
  31. development_finance-0.1.0/src/dfo/_datasets/bundle.py +823 -0
  32. development_finance-0.1.0/src/dfo/_datasets/cache.py +439 -0
  33. development_finance-0.1.0/src/dfo/_datasets/cards.py +333 -0
  34. development_finance-0.1.0/src/dfo/_datasets/dataset.py +1710 -0
  35. development_finance-0.1.0/src/dfo/_datasets/fetch.py +763 -0
  36. development_finance-0.1.0/src/dfo/_datasets/interrupt.py +31 -0
  37. development_finance-0.1.0/src/dfo/_datasets/operation.py +567 -0
  38. development_finance-0.1.0/src/dfo/_datasets/pin.py +109 -0
  39. development_finance-0.1.0/src/dfo/_datasets/progress.py +160 -0
  40. development_finance-0.1.0/src/dfo/_datasets/publication.py +667 -0
  41. development_finance-0.1.0/src/dfo/_datasets/recipe.py +595 -0
  42. development_finance-0.1.0/src/dfo/_datasets/resolve.py +1199 -0
  43. development_finance-0.1.0/src/dfo/_datasets/shares.py +381 -0
  44. development_finance-0.1.0/src/dfo/_generated/__init__.py +1 -0
  45. development_finance-0.1.0/src/dfo/_generated/error_map.py +288 -0
  46. development_finance-0.1.0/src/dfo/_generated/file_selector.py +10 -0
  47. development_finance-0.1.0/src/dfo/_generated/reading_order.py +25 -0
  48. development_finance-0.1.0/src/dfo/_generated/role_keys.py +18 -0
  49. development_finance-0.1.0/src/dfo/_generated/strict.py +69 -0
  50. development_finance-0.1.0/src/dfo/_generated/wire.py +3765 -0
  51. development_finance-0.1.0/src/dfo/_http/__init__.py +1 -0
  52. development_finance-0.1.0/src/dfo/_http/hooks.py +199 -0
  53. development_finance-0.1.0/src/dfo/_http/transport.py +1442 -0
  54. development_finance-0.1.0/src/dfo/_search/__init__.py +1 -0
  55. development_finance-0.1.0/src/dfo/_search/answer.py +837 -0
  56. development_finance-0.1.0/src/dfo/_search/answer_json.py +126 -0
  57. development_finance-0.1.0/src/dfo/_search/display.py +256 -0
  58. development_finance-0.1.0/src/dfo/_search/lookups.py +221 -0
  59. development_finance-0.1.0/src/dfo/_search/search.py +46 -0
  60. development_finance-0.1.0/src/dfo/_sync/__init__.py +1 -0
  61. development_finance-0.1.0/src/dfo/_sync/_client/__init__.py +1 -0
  62. development_finance-0.1.0/src/dfo/_sync/_client/client.py +498 -0
  63. development_finance-0.1.0/src/dfo/_sync/_datasets/__init__.py +1 -0
  64. development_finance-0.1.0/src/dfo/_sync/_datasets/analysis.py +655 -0
  65. development_finance-0.1.0/src/dfo/_sync/_datasets/cards.py +334 -0
  66. development_finance-0.1.0/src/dfo/_sync/_datasets/fetch.py +764 -0
  67. development_finance-0.1.0/src/dfo/_sync/_datasets/operation.py +564 -0
  68. development_finance-0.1.0/src/dfo/_sync/_datasets/pin.py +110 -0
  69. development_finance-0.1.0/src/dfo/_sync/_datasets/resolve.py +1198 -0
  70. development_finance-0.1.0/src/dfo/_sync/_datasets/shares.py +378 -0
  71. development_finance-0.1.0/src/dfo/_sync/_http/__init__.py +1 -0
  72. development_finance-0.1.0/src/dfo/_sync/_http/transport.py +1441 -0
  73. development_finance-0.1.0/src/dfo/_sync/_search/__init__.py +1 -0
  74. development_finance-0.1.0/src/dfo/_sync/_search/answer.py +838 -0
  75. development_finance-0.1.0/src/dfo/_sync/_search/lookups.py +220 -0
  76. development_finance-0.1.0/src/dfo/_sync/_search/search.py +47 -0
  77. development_finance-0.1.0/src/dfo/_version.py +3 -0
  78. development_finance-0.1.0/src/dfo/analysis.py +37 -0
  79. development_finance-0.1.0/src/dfo/auth.py +19 -0
  80. development_finance-0.1.0/src/dfo/cards.py +13 -0
  81. development_finance-0.1.0/src/dfo/errors.py +65 -0
  82. development_finance-0.1.0/src/dfo/lookup.py +18 -0
  83. development_finance-0.1.0/src/dfo/py.typed +1 -0
  84. development_finance-0.1.0/src/dfo/types.py +149 -0
@@ -0,0 +1,307 @@
1
+ Metadata-Version: 2.3
2
+ Name: development-finance
3
+ Version: 0.1.0
4
+ Summary: Python client for the Development Finance Observatory APIs
5
+ Keywords: development finance,ODA,aid,SDMX,Data Commons
6
+ Author: ONE Campaign
7
+ Author-email: ONE Campaign <data@one.org>
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: httpcore2>=2.15,<2.16 ; sys_platform != 'emscripten'
14
+ Requires-Dist: httpx2>=2.15,<2.16
15
+ Requires-Dist: pyarrow>=15
16
+ Requires-Dist: typing-extensions>=4.12
17
+ Requires-Dist: pandas>=2.2 ; extra == 'pandas'
18
+ Requires-Dist: polars>=1.0 ; extra == 'polars'
19
+ Requires-Dist: openpyxl>=3.1 ; extra == 'xlsx'
20
+ Requires-Python: >=3.12
21
+ Project-URL: Homepage, https://developmentfinance.org
22
+ Project-URL: Repository, https://github.com/DevelopmentFinanceObservatory/sdk
23
+ Project-URL: Issues, https://github.com/DevelopmentFinanceObservatory/sdk/issues
24
+ Project-URL: Changelog, https://github.com/DevelopmentFinanceObservatory/sdk/blob/main/python/CHANGELOG.md
25
+ Provides-Extra: pandas
26
+ Provides-Extra: polars
27
+ Provides-Extra: xlsx
28
+ Description-Content-Type: text/markdown
29
+
30
+ # development-finance
31
+
32
+ Ask the Development Finance Observatory (DFO) a question about aid, debt, or development indicators in your own words, and get a versioned table back.
33
+
34
+ ```bash
35
+ uv add "development-finance[pandas]" # in a uv project
36
+ pip install "development-finance[pandas]" # with pip
37
+ ```
38
+
39
+ ```python
40
+ import dfo
41
+
42
+ answer = dfo.search("ODA from Germany to Nigeria")
43
+ print(answer)
44
+ data = answer.fetch()
45
+ df = data.to_pandas()
46
+
47
+ growth = dfo.analysis.growth(data, window=(2015, 2024))
48
+ share = dfo.share(dfo.cards.line(data, title="German ODA to Nigeria"), latest=True)
49
+ print(share.url)
50
+ ```
51
+
52
+ ```text
53
+ Germany's gross disbursements to Nigeria for official development assistance, 1960 to 2024. Current US dollars · OECD DAC2a.
54
+ Fetch: answer.fetch()
55
+ https://developmentfinance.org/s/k3Qx8vTa
56
+ ```
57
+
58
+ `dfo.analysis.growth` computes the change from 2015 to 2024 on DFO, as a fraction where 0.25 is a 25% rise. `dfo.share` publishes a line chart of the data at a public URL that anyone with the link can open, and follows new data.
59
+
60
+ The package imports as `dfo` and needs Python 3.12 or later. It works without a key, under a per-IP quota. The table arrives as Arrow. The `pandas` extra adds `to_pandas()`, `polars` adds `to_polars()`, and `xlsx` lets `dfo.load` open an XLSX bundle from the website.
61
+
62
+ The code blocks on this page run in order, as cells of one notebook.
63
+
64
+ ## Ask a question and read the answer
65
+
66
+ `dfo.search` returns an `Answer`, which says what DFO understood and holds no data yet. Printing it shows Search's sentence, naming the measure, places, years, unit, and source, and the line to type next. Read the sentence before you fetch. Search is model-driven, and a wrong reading gives a plausible table for the wrong question.
67
+
68
+ `answer.fetch()` builds the table on the server, downloads it, and checks the file against its digest. Printing the dataset shows what it is, how big, and how to keep it:
69
+
70
+ ```text
71
+ <Dataset Germany's gross disbursements to Nigeria for official development assistance, 1960 to 2024. Current US dollars · OECD DAC2a.
72
+ Source: OECD DAC2A
73
+ Version: cv_1145ade0abb33920fefaf5daaeb8f3a9 (exact), held until 24 November 2026, not pinned
74
+ Rows: 65
75
+ Keep: data.save(path) · newest later: dfo.fetch(data.ref, latest=True)>
76
+ time_period financingProvider_label financingRecipient_label value
77
+ 60 2020 Germany Nigeria 123608639.0
78
+ 61 2021 Germany Nigeria 164254178.0
79
+ 62 2022 Germany Nigeria 122707200.0
80
+ 63 2023 Germany Nigeria 126863832.0
81
+ 64 2024 Germany Nigeria 100707102.0
82
+ ```
83
+
84
+ ```python
85
+ import dfo
86
+
87
+ answer = dfo.search("ODA from Germany to Nigeria")
88
+ data = answer.fetch()
89
+ print(data)
90
+
91
+ df = data.to_pandas()
92
+ print(df.iloc[:, :4].tail())
93
+ ```
94
+
95
+ The `_label` columns of names come first, covering the year, the parties, the value, and its unit. The other columns follow, codes included, with those that tell the rows apart before those that hold one value throughout, and `stat_var` last. The years of each series read in order. `len(data)` is the row count, `data.to_polars()` gives a polars frame, and `pyarrow.table(data)` gives an Arrow table. While a build runs on the server, `fetch` shows its progress in a terminal or notebook. [Long builds](docs/long-builds.md) covers waiting, timeouts, and Ctrl-C.
96
+
97
+ ## Choose a reading
98
+
99
+ Some questions have more than one reading. "government education spending Kenya" can mean a share of the budget or a share of GDP, and the answer lists both with the line that settles it:
100
+
101
+ ```text
102
+ Answered 0 of 1 part of your question.
103
+ missing "government education spending": 2 readings
104
+ [0] Kenya's percent of total government expenditure on education, 1980 to 2025 · World Development Indicators
105
+ [1] Kenya's percent of nominal GDP spent on education, 1971 to 2024 · World Development Indicators
106
+ Choose: answer.choose(0).fetch()
107
+ Kenya's percent of total government expenditure on education, 1980 to 2025. World Development Indicators.
108
+ ```
109
+
110
+ ```python
111
+ answer = dfo.search("government education spending Kenya")
112
+ print(answer)
113
+
114
+ data = answer.choose(0).fetch()
115
+ print(data.sentence)
116
+ ```
117
+
118
+ `choose` takes the option's index or its full label, and makes one Search request. The new answer and its sentence are Search's. The order of readings can change between searches, so a script that runs again later keeps `data.ref` and fetches that. A place that matches several countries, such as "Congo", is settled the same way, with `answer.choose("Congo [DRC]")`. [Answers and choices](docs/answers.md) covers questions with several parts and answers that leave a part out.
119
+
120
+ ## Fetch other years or places
121
+
122
+ The simplest way is to put the years and places in the question:
123
+
124
+ ```text
125
+ Germany's gross disbursements to Ghana and Kenya for official development assistance, 2015 to 2022. Current US dollars · OECD DAC2a.
126
+ Fetch: answer.fetch()
127
+ ```
128
+
129
+ ```python
130
+ answer = dfo.search("ODA from Germany to Kenya and Ghana, 2015 to 2022")
131
+ print(answer)
132
+ ```
133
+
134
+ To vary a reading you already have without a new search, pass its spec to `dfo.fetch` with `window` and `entities`. `window` takes `(start_year, end_year)` with years from 1000 to 2200, `"latest"`, or `"all"`. `entities` maps each role the spec names to place codes, keyed `place` for the place a measure is about and `origin` and `destination` for a flow, and `spec.roles` lists those roles with the places each binds, or None for a role left open. `answer.fetch` takes the same arguments and narrows every reading in the answer:
135
+
136
+ ```text
137
+ <Dataset Germany's gross disbursements to Ghana and Kenya for official development assistance, 2015 to 2022. Current US dollars · OECD DAC2a.
138
+ Source: OECD DAC2A
139
+ Version: cv_6cdec57990a5712642ad97ab58d9ea21 (exact), held until 24 November 2026, not pinned
140
+ Rows: 16
141
+ Keep: data.save(path) · newest later: dfo.fetch(data.ref, latest=True)>
142
+ ```
143
+
144
+ ```python
145
+ spec = dfo.search("ODA from Germany to Nigeria").specs[0]
146
+ print(spec.roles)
147
+ # {'destination': ('country/NGA',), 'origin': ('country/DEU',)}
148
+
149
+ data = dfo.fetch(
150
+ spec,
151
+ window=(2015, 2022),
152
+ entities={"destination": ["country/KEN", "country/GHA"]},
153
+ )
154
+ print(data)
155
+ ```
156
+
157
+ [Look up a place code](#look-up-a-place-code) finds the code for a name. [Datasets and versions](docs/datasets.md) covers `facets` and the frame layout.
158
+
159
+ ## Keep group and total rows
160
+
161
+ A table of flows leaves out the rows for groups and totals, such as "DAC countries" or "official donors (DAC)", so a sum over it counts each donor once. Pass `party_totals=True` to keep them. For ODA to Ethiopia in 2022, the default table has 95 rows, one per donor, and the full one has 110:
162
+
163
+ ```python
164
+ answer = dfo.search("ODA to Ethiopia in 2022 by donor")
165
+ by_donor = answer.fetch()
166
+ with_totals = answer.fetch(party_totals=True)
167
+
168
+ print(len(by_donor), len(with_totals)) # 95 110
169
+ print(with_totals.to_pandas()["financingProvider_kind"].value_counts())
170
+ ```
171
+
172
+ ```text
173
+ financingProvider_kind
174
+ official 72
175
+ private 23
176
+ total 13
177
+ whole total 2
178
+ Name: count, dtype: int64
179
+ ```
180
+
181
+ `financingProvider_kind` tells the rows apart: `official` and `private` for one donor, `total` for a group, and `whole total` for the source's figure across every donor. Both fetches issue a `SearchWarning` that no donor was named, which is the reading wanted here. `dfo.fetch(spec, party_totals=True)` does the same for a spec.
182
+
183
+ ## Save the data and load it later
184
+
185
+ `data.save(path)` writes one Parquet file that carries the table, the citation, and the ref. `dfo.load(path)` reads it back offline:
186
+
187
+ ```python
188
+ data = dfo.search("Kenya's external debt service").fetch()
189
+ data.save("kenya-debt.parquet")
190
+
191
+ saved = dfo.load("kenya-debt.parquet")
192
+ print(saved.ref == data.ref, len(saved)) # True 55
193
+ print(saved.citation.text)
194
+ ```
195
+
196
+ The file is the record of what you analysed. The Parquet file reads in any Arrow tool, and its provenance sits in the file's metadata under `dfo.*` keys.
197
+
198
+ ## Refresh later and tell if the data changed
199
+
200
+ `data.ref` names the exact version a dataset holds. `dfo.fetch(ref)` returns those rows again, and `dfo.fetch(ref, latest=True)` builds the newest version of the same reading. Two refs are equal when they name the same versions:
201
+
202
+ ```python
203
+ old = dfo.load("kenya-debt.parquet")
204
+ new = dfo.fetch(old.ref, latest=True)
205
+
206
+ if new.ref != old.ref:
207
+ print("DFO's data has changed since you saved it")
208
+ new.save("kenya-debt.parquet")
209
+ ```
210
+
211
+ A version marked "held until" in the dataset's display can be fetched by its ref until that date, and one marked "pinned" is kept without a limit. The saved file keeps its rows either way. [Datasets and versions](docs/datasets.md) covers refs and retention.
212
+
213
+ ## Share an answer
214
+
215
+ `answer.to_json()` writes the answer as JSON, and `dfo.Answer.from_json(text)` reads it back, ready to choose and fetch. A web app uses this to show the readings on one request and build the reader's choice on the next:
216
+
217
+ ```python
218
+ answer = dfo.search("government education spending Kenya")
219
+ text = answer.to_json()
220
+
221
+ restored = dfo.Answer.from_json(text)
222
+ data = restored.choose(1).fetch()
223
+ print(data.sentence)
224
+ ```
225
+
226
+ Answer JSON that comes back from a browser can carry any reading, and its build runs on your key. [Answers and choices](docs/answers.md) says how to keep a web app safe with it.
227
+
228
+ ## Look up a place code
229
+
230
+ `dfo.lookup.resolve` turns names into the codes `entities` takes. It returns one `Match` per name, which prints what the name resolved to. A name that matches several places lists them, and a misspelt name names the nearest:
231
+
232
+ ```python
233
+ for match in dfo.lookup.resolve("Nigeria", "Congo", "Germny"):
234
+ print(match)
235
+ ```
236
+
237
+ ```text
238
+ Nigeria -> Nigeria country/NGA (country)
239
+ Congo -> ambiguous: Congo [DRC] country/COD | Congo [Republic] country/COG
240
+ Germny -> not found; nearest: Germany country/DEU
241
+ ```
242
+
243
+ `match.entity.dcid` holds the code of a resolved name, `match.candidates` the places an ambiguous one could mean, and `match.near_matches` the nearest spellings. `match.raw` keeps the server's result. `resolve` also takes `kinds`, such as `["country"]`, to limit the node kinds a name may match, `code_system` with `kinds=["code"]` to match one code system's codes, such as `CRSPurposeCodeEnum`, and `expand_members=True` to inline each group's members. `dfo.lookup.node("country/NGA")` returns one place, with the regions it belongs to in `node.raw`, and `as_of=` reads the graph on a past date. `dfo.lookup.code("spec_not_found")` explains a code from an error, and `dfo.lookup.catalogue()` lists what Search holds data on, with example questions. The result types live in `dfo.types`.
244
+
245
+ ## Settings, keys, and errors
246
+
247
+ `dfo.search` and `dfo.fetch` use a default client that reads `DFO_BASE_URL` and your stored key. `dfo.Client` carries the same calls with its own settings, and closes its connections on exit:
248
+
249
+ ```python
250
+ with dfo.Client(timeout=60.0, max_retries=3) as client:
251
+ data = client.search("Kenya's external debt service").fetch()
252
+ print(len(data))
253
+ ```
254
+
255
+ `Client` takes `api_key`, `base_url`, `timeout` (one request attempt, 30 seconds by default), `max_retries` (0 to 10, default 2), `cache_dir`, `cache`, `headers`, and `header_provider`. `dfo.configure(...)` takes the same options and replaces the default client. `dfo.AsyncClient` has the same methods as coroutines. `client.lookup` holds the lookups. `client.with_options(timeout=..., max_retries=...)` returns a copy with other limits that shares the client's connections and cache.
256
+
257
+ Run `dfo auth login` to store a key, and `dfo auth status` to see which key the client sends. [Keys and credentials](docs/keys.md) covers the lookup order.
258
+
259
+ Every error derives from `dfo.errors.DfoError`, carries a `code`, and ends its message with `[code]`, or `[code · trace id]` when a response carried one. A refusal names the fix, such as the call that settles an incomplete answer. [Errors](docs/errors.md) lists every class by what you do next. The types answers and datasets contain live in `dfo.types`.
260
+
261
+ ## Limits the client enforces
262
+
263
+ The Python and TypeScript clients both apply these limits. The constant names are those in the Python source.
264
+
265
+ | Rule | Value |
266
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
267
+ | `Retry-After` parse | ASCII digits (seconds) or an IMF-fixdate such as `Sun, 06 Nov 1994 08:49:37 GMT`. Any other value is ignored, and the client's own backoff applies. A date in the past means 0 |
268
+ | `Retry-After` wait | The client waits when the value is at most 60 s (`RETRY_AFTER_MAX_S`) and fits the time left before the call's deadline. Otherwise it raises the refusal's typed error at once with `retry_after` set |
269
+ | Poll interval | The server's value, or a refused poll's `Retry-After`, clamped to 1 s (`POLL_INTERVAL_MIN_S`) to 60 s (`POLL_INTERVAL_MAX_S`), then to the time left before the deadline, where the client polls once more |
270
+ | Versions walk | Pages of 100 versions, at most 20 pages (`VERSIONS_PAGE_BUDGET`). A page cursor seen before raises `contract_violation`. Over the budget, no newest version is reported |
271
+ | Body size | Decoded bytes are counted while the body streams, and the read stops at the limit with `response_too_large`. A file's limit is its manifest's `bytes`, else 2 GiB (`DOWNLOAD_MAX_BYTES`). A JSON body's limit is 16 MiB (`JSON_BODY_MAX_BYTES`) |
272
+ | Keys | Visible ASCII only, 0x21 to 0x7E. The key-format check (prefix, base62, checksum) only warns |
273
+ | Header values | Visible ASCII, space, and tab. A control character, DEL, or a non-ASCII character raises `header_invalid` |
274
+ | `timeout` | A finite number above 0. A call's own `timeout` bounds the whole call (60 seconds for `search`, and no limit for `fetch` unless you pass one) |
275
+ | `max_retries` | An integer from 0 to 10. A bool is refused |
276
+ | Error messages | Name the source and the position of the offending character, and leave the key or header value out |
277
+
278
+ A key travels only to an https base URL, or to a loopback one over http: `localhost`, a name under `.localhost`, an address in 127.0.0.0/8 written as four decimal numbers, or `[::1]`. Any other plain-http base URL with a key raises `insecure_transport`. In synchronous code, platform name resolution can run past a deadline, and the asynchronous client can cancel it.
279
+
280
+ A process forked from a threaded parent keeps the default client and the proxy settings the parent resolved. A client the child builds for itself reads the platform's proxy settings in the child, which can crash a macOS child forked from a process with running threads. Build the client in the parent, or start workers with `spawn`.
281
+
282
+ The TypeScript client follows the same contract. It spells "no limit" as `Infinity` where Python uses `None`, decodes a Parquet file's row values on first use where Python decodes them when `fetch` returns, and writes refs without spaces after separators. Each client reads the other's refs.
283
+
284
+ ## Documentation
285
+
286
+ - [Answers and choices](docs/answers.md): parts, readings, `choose`, partial fetches, shared answers, and declined questions.
287
+ - [Datasets and versions](docs/datasets.md): narrowing a fetch, layouts, conversions, refs, retention, `save`, and `load`.
288
+ - [Long builds](docs/long-builds.md): progress, deadlines, and what Ctrl-C leaves running.
289
+ - [Compute a figure on DFO](docs/compute.md): `dfo.analysis`, recipes, `latest_year`, recomputing on new data, and the series an operation leaves out.
290
+ - [Cards and shares](docs/shares.md): charts, tables and headline numbers, public share links, write tokens, snapshots, and what a share showed on a date.
291
+ - [Errors](docs/errors.md): every error class and code, by what you do next.
292
+ - [Keys and credentials](docs/keys.md): the key lookup order, `dfo auth login`, and the credentials file.
293
+ - [The cache](docs/cache.md): where downloads go, `DFO_OFFLINE`, and stale reads.
294
+
295
+ [llms.txt](llms.txt) summarises the package for language models.
296
+
297
+ ## Development
298
+
299
+ The repository root holds the contract, the pre-commit configuration, and the CI workflows, and [`CONTRIBUTING.md`](../CONTRIBUTING.md) there explains them. From this directory:
300
+
301
+ ```bash
302
+ uv sync
303
+ uv run pytest
304
+ uv run ruff check
305
+ uv run ruff format --check
306
+ uv run ty check
307
+ ```
@@ -0,0 +1,278 @@
1
+ # development-finance
2
+
3
+ Ask the Development Finance Observatory (DFO) a question about aid, debt, or development indicators in your own words, and get a versioned table back.
4
+
5
+ ```bash
6
+ uv add "development-finance[pandas]" # in a uv project
7
+ pip install "development-finance[pandas]" # with pip
8
+ ```
9
+
10
+ ```python
11
+ import dfo
12
+
13
+ answer = dfo.search("ODA from Germany to Nigeria")
14
+ print(answer)
15
+ data = answer.fetch()
16
+ df = data.to_pandas()
17
+
18
+ growth = dfo.analysis.growth(data, window=(2015, 2024))
19
+ share = dfo.share(dfo.cards.line(data, title="German ODA to Nigeria"), latest=True)
20
+ print(share.url)
21
+ ```
22
+
23
+ ```text
24
+ Germany's gross disbursements to Nigeria for official development assistance, 1960 to 2024. Current US dollars · OECD DAC2a.
25
+ Fetch: answer.fetch()
26
+ https://developmentfinance.org/s/k3Qx8vTa
27
+ ```
28
+
29
+ `dfo.analysis.growth` computes the change from 2015 to 2024 on DFO, as a fraction where 0.25 is a 25% rise. `dfo.share` publishes a line chart of the data at a public URL that anyone with the link can open, and follows new data.
30
+
31
+ The package imports as `dfo` and needs Python 3.12 or later. It works without a key, under a per-IP quota. The table arrives as Arrow. The `pandas` extra adds `to_pandas()`, `polars` adds `to_polars()`, and `xlsx` lets `dfo.load` open an XLSX bundle from the website.
32
+
33
+ The code blocks on this page run in order, as cells of one notebook.
34
+
35
+ ## Ask a question and read the answer
36
+
37
+ `dfo.search` returns an `Answer`, which says what DFO understood and holds no data yet. Printing it shows Search's sentence, naming the measure, places, years, unit, and source, and the line to type next. Read the sentence before you fetch. Search is model-driven, and a wrong reading gives a plausible table for the wrong question.
38
+
39
+ `answer.fetch()` builds the table on the server, downloads it, and checks the file against its digest. Printing the dataset shows what it is, how big, and how to keep it:
40
+
41
+ ```text
42
+ <Dataset Germany's gross disbursements to Nigeria for official development assistance, 1960 to 2024. Current US dollars · OECD DAC2a.
43
+ Source: OECD DAC2A
44
+ Version: cv_1145ade0abb33920fefaf5daaeb8f3a9 (exact), held until 24 November 2026, not pinned
45
+ Rows: 65
46
+ Keep: data.save(path) · newest later: dfo.fetch(data.ref, latest=True)>
47
+ time_period financingProvider_label financingRecipient_label value
48
+ 60 2020 Germany Nigeria 123608639.0
49
+ 61 2021 Germany Nigeria 164254178.0
50
+ 62 2022 Germany Nigeria 122707200.0
51
+ 63 2023 Germany Nigeria 126863832.0
52
+ 64 2024 Germany Nigeria 100707102.0
53
+ ```
54
+
55
+ ```python
56
+ import dfo
57
+
58
+ answer = dfo.search("ODA from Germany to Nigeria")
59
+ data = answer.fetch()
60
+ print(data)
61
+
62
+ df = data.to_pandas()
63
+ print(df.iloc[:, :4].tail())
64
+ ```
65
+
66
+ The `_label` columns of names come first, covering the year, the parties, the value, and its unit. The other columns follow, codes included, with those that tell the rows apart before those that hold one value throughout, and `stat_var` last. The years of each series read in order. `len(data)` is the row count, `data.to_polars()` gives a polars frame, and `pyarrow.table(data)` gives an Arrow table. While a build runs on the server, `fetch` shows its progress in a terminal or notebook. [Long builds](docs/long-builds.md) covers waiting, timeouts, and Ctrl-C.
67
+
68
+ ## Choose a reading
69
+
70
+ Some questions have more than one reading. "government education spending Kenya" can mean a share of the budget or a share of GDP, and the answer lists both with the line that settles it:
71
+
72
+ ```text
73
+ Answered 0 of 1 part of your question.
74
+ missing "government education spending": 2 readings
75
+ [0] Kenya's percent of total government expenditure on education, 1980 to 2025 · World Development Indicators
76
+ [1] Kenya's percent of nominal GDP spent on education, 1971 to 2024 · World Development Indicators
77
+ Choose: answer.choose(0).fetch()
78
+ Kenya's percent of total government expenditure on education, 1980 to 2025. World Development Indicators.
79
+ ```
80
+
81
+ ```python
82
+ answer = dfo.search("government education spending Kenya")
83
+ print(answer)
84
+
85
+ data = answer.choose(0).fetch()
86
+ print(data.sentence)
87
+ ```
88
+
89
+ `choose` takes the option's index or its full label, and makes one Search request. The new answer and its sentence are Search's. The order of readings can change between searches, so a script that runs again later keeps `data.ref` and fetches that. A place that matches several countries, such as "Congo", is settled the same way, with `answer.choose("Congo [DRC]")`. [Answers and choices](docs/answers.md) covers questions with several parts and answers that leave a part out.
90
+
91
+ ## Fetch other years or places
92
+
93
+ The simplest way is to put the years and places in the question:
94
+
95
+ ```text
96
+ Germany's gross disbursements to Ghana and Kenya for official development assistance, 2015 to 2022. Current US dollars · OECD DAC2a.
97
+ Fetch: answer.fetch()
98
+ ```
99
+
100
+ ```python
101
+ answer = dfo.search("ODA from Germany to Kenya and Ghana, 2015 to 2022")
102
+ print(answer)
103
+ ```
104
+
105
+ To vary a reading you already have without a new search, pass its spec to `dfo.fetch` with `window` and `entities`. `window` takes `(start_year, end_year)` with years from 1000 to 2200, `"latest"`, or `"all"`. `entities` maps each role the spec names to place codes, keyed `place` for the place a measure is about and `origin` and `destination` for a flow, and `spec.roles` lists those roles with the places each binds, or None for a role left open. `answer.fetch` takes the same arguments and narrows every reading in the answer:
106
+
107
+ ```text
108
+ <Dataset Germany's gross disbursements to Ghana and Kenya for official development assistance, 2015 to 2022. Current US dollars · OECD DAC2a.
109
+ Source: OECD DAC2A
110
+ Version: cv_6cdec57990a5712642ad97ab58d9ea21 (exact), held until 24 November 2026, not pinned
111
+ Rows: 16
112
+ Keep: data.save(path) · newest later: dfo.fetch(data.ref, latest=True)>
113
+ ```
114
+
115
+ ```python
116
+ spec = dfo.search("ODA from Germany to Nigeria").specs[0]
117
+ print(spec.roles)
118
+ # {'destination': ('country/NGA',), 'origin': ('country/DEU',)}
119
+
120
+ data = dfo.fetch(
121
+ spec,
122
+ window=(2015, 2022),
123
+ entities={"destination": ["country/KEN", "country/GHA"]},
124
+ )
125
+ print(data)
126
+ ```
127
+
128
+ [Look up a place code](#look-up-a-place-code) finds the code for a name. [Datasets and versions](docs/datasets.md) covers `facets` and the frame layout.
129
+
130
+ ## Keep group and total rows
131
+
132
+ A table of flows leaves out the rows for groups and totals, such as "DAC countries" or "official donors (DAC)", so a sum over it counts each donor once. Pass `party_totals=True` to keep them. For ODA to Ethiopia in 2022, the default table has 95 rows, one per donor, and the full one has 110:
133
+
134
+ ```python
135
+ answer = dfo.search("ODA to Ethiopia in 2022 by donor")
136
+ by_donor = answer.fetch()
137
+ with_totals = answer.fetch(party_totals=True)
138
+
139
+ print(len(by_donor), len(with_totals)) # 95 110
140
+ print(with_totals.to_pandas()["financingProvider_kind"].value_counts())
141
+ ```
142
+
143
+ ```text
144
+ financingProvider_kind
145
+ official 72
146
+ private 23
147
+ total 13
148
+ whole total 2
149
+ Name: count, dtype: int64
150
+ ```
151
+
152
+ `financingProvider_kind` tells the rows apart: `official` and `private` for one donor, `total` for a group, and `whole total` for the source's figure across every donor. Both fetches issue a `SearchWarning` that no donor was named, which is the reading wanted here. `dfo.fetch(spec, party_totals=True)` does the same for a spec.
153
+
154
+ ## Save the data and load it later
155
+
156
+ `data.save(path)` writes one Parquet file that carries the table, the citation, and the ref. `dfo.load(path)` reads it back offline:
157
+
158
+ ```python
159
+ data = dfo.search("Kenya's external debt service").fetch()
160
+ data.save("kenya-debt.parquet")
161
+
162
+ saved = dfo.load("kenya-debt.parquet")
163
+ print(saved.ref == data.ref, len(saved)) # True 55
164
+ print(saved.citation.text)
165
+ ```
166
+
167
+ The file is the record of what you analysed. The Parquet file reads in any Arrow tool, and its provenance sits in the file's metadata under `dfo.*` keys.
168
+
169
+ ## Refresh later and tell if the data changed
170
+
171
+ `data.ref` names the exact version a dataset holds. `dfo.fetch(ref)` returns those rows again, and `dfo.fetch(ref, latest=True)` builds the newest version of the same reading. Two refs are equal when they name the same versions:
172
+
173
+ ```python
174
+ old = dfo.load("kenya-debt.parquet")
175
+ new = dfo.fetch(old.ref, latest=True)
176
+
177
+ if new.ref != old.ref:
178
+ print("DFO's data has changed since you saved it")
179
+ new.save("kenya-debt.parquet")
180
+ ```
181
+
182
+ A version marked "held until" in the dataset's display can be fetched by its ref until that date, and one marked "pinned" is kept without a limit. The saved file keeps its rows either way. [Datasets and versions](docs/datasets.md) covers refs and retention.
183
+
184
+ ## Share an answer
185
+
186
+ `answer.to_json()` writes the answer as JSON, and `dfo.Answer.from_json(text)` reads it back, ready to choose and fetch. A web app uses this to show the readings on one request and build the reader's choice on the next:
187
+
188
+ ```python
189
+ answer = dfo.search("government education spending Kenya")
190
+ text = answer.to_json()
191
+
192
+ restored = dfo.Answer.from_json(text)
193
+ data = restored.choose(1).fetch()
194
+ print(data.sentence)
195
+ ```
196
+
197
+ Answer JSON that comes back from a browser can carry any reading, and its build runs on your key. [Answers and choices](docs/answers.md) says how to keep a web app safe with it.
198
+
199
+ ## Look up a place code
200
+
201
+ `dfo.lookup.resolve` turns names into the codes `entities` takes. It returns one `Match` per name, which prints what the name resolved to. A name that matches several places lists them, and a misspelt name names the nearest:
202
+
203
+ ```python
204
+ for match in dfo.lookup.resolve("Nigeria", "Congo", "Germny"):
205
+ print(match)
206
+ ```
207
+
208
+ ```text
209
+ Nigeria -> Nigeria country/NGA (country)
210
+ Congo -> ambiguous: Congo [DRC] country/COD | Congo [Republic] country/COG
211
+ Germny -> not found; nearest: Germany country/DEU
212
+ ```
213
+
214
+ `match.entity.dcid` holds the code of a resolved name, `match.candidates` the places an ambiguous one could mean, and `match.near_matches` the nearest spellings. `match.raw` keeps the server's result. `resolve` also takes `kinds`, such as `["country"]`, to limit the node kinds a name may match, `code_system` with `kinds=["code"]` to match one code system's codes, such as `CRSPurposeCodeEnum`, and `expand_members=True` to inline each group's members. `dfo.lookup.node("country/NGA")` returns one place, with the regions it belongs to in `node.raw`, and `as_of=` reads the graph on a past date. `dfo.lookup.code("spec_not_found")` explains a code from an error, and `dfo.lookup.catalogue()` lists what Search holds data on, with example questions. The result types live in `dfo.types`.
215
+
216
+ ## Settings, keys, and errors
217
+
218
+ `dfo.search` and `dfo.fetch` use a default client that reads `DFO_BASE_URL` and your stored key. `dfo.Client` carries the same calls with its own settings, and closes its connections on exit:
219
+
220
+ ```python
221
+ with dfo.Client(timeout=60.0, max_retries=3) as client:
222
+ data = client.search("Kenya's external debt service").fetch()
223
+ print(len(data))
224
+ ```
225
+
226
+ `Client` takes `api_key`, `base_url`, `timeout` (one request attempt, 30 seconds by default), `max_retries` (0 to 10, default 2), `cache_dir`, `cache`, `headers`, and `header_provider`. `dfo.configure(...)` takes the same options and replaces the default client. `dfo.AsyncClient` has the same methods as coroutines. `client.lookup` holds the lookups. `client.with_options(timeout=..., max_retries=...)` returns a copy with other limits that shares the client's connections and cache.
227
+
228
+ Run `dfo auth login` to store a key, and `dfo auth status` to see which key the client sends. [Keys and credentials](docs/keys.md) covers the lookup order.
229
+
230
+ Every error derives from `dfo.errors.DfoError`, carries a `code`, and ends its message with `[code]`, or `[code · trace id]` when a response carried one. A refusal names the fix, such as the call that settles an incomplete answer. [Errors](docs/errors.md) lists every class by what you do next. The types answers and datasets contain live in `dfo.types`.
231
+
232
+ ## Limits the client enforces
233
+
234
+ The Python and TypeScript clients both apply these limits. The constant names are those in the Python source.
235
+
236
+ | Rule | Value |
237
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
238
+ | `Retry-After` parse | ASCII digits (seconds) or an IMF-fixdate such as `Sun, 06 Nov 1994 08:49:37 GMT`. Any other value is ignored, and the client's own backoff applies. A date in the past means 0 |
239
+ | `Retry-After` wait | The client waits when the value is at most 60 s (`RETRY_AFTER_MAX_S`) and fits the time left before the call's deadline. Otherwise it raises the refusal's typed error at once with `retry_after` set |
240
+ | Poll interval | The server's value, or a refused poll's `Retry-After`, clamped to 1 s (`POLL_INTERVAL_MIN_S`) to 60 s (`POLL_INTERVAL_MAX_S`), then to the time left before the deadline, where the client polls once more |
241
+ | Versions walk | Pages of 100 versions, at most 20 pages (`VERSIONS_PAGE_BUDGET`). A page cursor seen before raises `contract_violation`. Over the budget, no newest version is reported |
242
+ | Body size | Decoded bytes are counted while the body streams, and the read stops at the limit with `response_too_large`. A file's limit is its manifest's `bytes`, else 2 GiB (`DOWNLOAD_MAX_BYTES`). A JSON body's limit is 16 MiB (`JSON_BODY_MAX_BYTES`) |
243
+ | Keys | Visible ASCII only, 0x21 to 0x7E. The key-format check (prefix, base62, checksum) only warns |
244
+ | Header values | Visible ASCII, space, and tab. A control character, DEL, or a non-ASCII character raises `header_invalid` |
245
+ | `timeout` | A finite number above 0. A call's own `timeout` bounds the whole call (60 seconds for `search`, and no limit for `fetch` unless you pass one) |
246
+ | `max_retries` | An integer from 0 to 10. A bool is refused |
247
+ | Error messages | Name the source and the position of the offending character, and leave the key or header value out |
248
+
249
+ A key travels only to an https base URL, or to a loopback one over http: `localhost`, a name under `.localhost`, an address in 127.0.0.0/8 written as four decimal numbers, or `[::1]`. Any other plain-http base URL with a key raises `insecure_transport`. In synchronous code, platform name resolution can run past a deadline, and the asynchronous client can cancel it.
250
+
251
+ A process forked from a threaded parent keeps the default client and the proxy settings the parent resolved. A client the child builds for itself reads the platform's proxy settings in the child, which can crash a macOS child forked from a process with running threads. Build the client in the parent, or start workers with `spawn`.
252
+
253
+ The TypeScript client follows the same contract. It spells "no limit" as `Infinity` where Python uses `None`, decodes a Parquet file's row values on first use where Python decodes them when `fetch` returns, and writes refs without spaces after separators. Each client reads the other's refs.
254
+
255
+ ## Documentation
256
+
257
+ - [Answers and choices](docs/answers.md): parts, readings, `choose`, partial fetches, shared answers, and declined questions.
258
+ - [Datasets and versions](docs/datasets.md): narrowing a fetch, layouts, conversions, refs, retention, `save`, and `load`.
259
+ - [Long builds](docs/long-builds.md): progress, deadlines, and what Ctrl-C leaves running.
260
+ - [Compute a figure on DFO](docs/compute.md): `dfo.analysis`, recipes, `latest_year`, recomputing on new data, and the series an operation leaves out.
261
+ - [Cards and shares](docs/shares.md): charts, tables and headline numbers, public share links, write tokens, snapshots, and what a share showed on a date.
262
+ - [Errors](docs/errors.md): every error class and code, by what you do next.
263
+ - [Keys and credentials](docs/keys.md): the key lookup order, `dfo auth login`, and the credentials file.
264
+ - [The cache](docs/cache.md): where downloads go, `DFO_OFFLINE`, and stale reads.
265
+
266
+ [llms.txt](llms.txt) summarises the package for language models.
267
+
268
+ ## Development
269
+
270
+ The repository root holds the contract, the pre-commit configuration, and the CI workflows, and [`CONTRIBUTING.md`](../CONTRIBUTING.md) there explains them. From this directory:
271
+
272
+ ```bash
273
+ uv sync
274
+ uv run pytest
275
+ uv run ruff check
276
+ uv run ruff format --check
277
+ uv run ty check
278
+ ```