Skip to content

Commit c7a5569

Browse files
authored
fix(client-contract): ask the file selector only for the keys it reads (#33)
* fix(client-contract): ask the file selector only for the keys it reads A file-selector rule names format, bundle and layout; the validator, capture tool and selector scenarios derive the query from it. The validator compares values by one rule and reports malformed vectors, and the base-URL and client-identity rules close the cases where Python and WHATWG clients disagreed. * fix(client-contract): read a resumed build's rendering and keep nested columns out of sort keys A resumed operation's result takes the rendering its build posted, and refs are checked against the build that served their file. List and struct columns never sort, the reference same_value is the validator's, selector queries lowercase booleans only, and the capture's OECD prerequisite keeps party totals.
1 parent 455e958 commit c7a5569

22 files changed

Lines changed: 1517 additions & 155 deletions

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ uv run tools/validate_unit_vectors.py
4141
uv run tools/validate_provenance_docs_vectors.py
4242
uv run tools/validate_descriptions_vectors.py
4343
uv run tools/validate_client_contract.py
44-
uv run --with jsonschema --with pytest pytest tools/tests -q
44+
uv run --with jsonschema --with pyarrow --with pytest pytest tools/tests -q
4545
```
4646

4747
The last command tests the validators themselves. [tools/README.md](tools/README.md) says what each validator checks. Each package's tests drive its implementation against the same vectors or fixtures, and `uv run pytest` runs them.

‎python/client-contract/CHANGELOG.md‎

Lines changed: 8 additions & 6 deletions
Large diffs are not rendered by default.

‎python/client-contract/README.md‎

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

‎python/client-contract/src/dfo_client_contract/contract.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"reading-order",
2020
"client-identity",
2121
"role-keys",
22+
"file-selector",
2223
)
2324

2425

‎python/client-contract/src/dfo_client_contract/rules/README.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Client rules
22

3-
Clients implement these rules for the capabilities available in their runtime. Python implements every rule. TypeScript implements the key, credentials and base-URL rules in Node, and the reading-order, client-identity and role-keys rules in every runtime; browser runtimes use the key sources available there. TypeScript currently keeps no disk cache, so `cache-dir` and `cache-layout` apply to Python. Clients implementing the same capability use the same rules and formats. Each rule's vectors are in `<rule>.json`, validated by `rules.schema.json`. In a vector, `env` lists the environment variables set, and an empty value counts as unset everywhere below.
3+
Clients implement these rules for the capabilities available in their runtime. Python implements every rule. TypeScript implements the key, credentials and base-URL rules in Node, and the reading-order, client-identity, role-keys and file-selector rules in every runtime; browser runtimes use the key sources available there. TypeScript currently keeps no disk cache, so `cache-dir` and `cache-layout` apply to Python. Clients implementing the same capability use the same rules and formats. Each rule's vectors are in `<rule>.json`, validated by `rules.schema.json`. In a vector, `env` lists the environment variables set, and an empty value counts as unset everywhere below.
44

55
## Credentials path (`credentials-path`)
66

@@ -42,7 +42,7 @@ A client resolves its base URL when it is built. The first source found wins:
4242
1. `DFO_BASE_URL`;
4343
1. the public default, `https://developmentfinance.org/api/v1`.
4444

45-
Only the chosen source is checked. A base URL is valid when it is an absolute `http` or `https` URL with a host and none of these: userinfo, an empty one as in `https://@host/api` included; a query; a fragment; a `.` or `..` path segment; a character outside visible ASCII, a control character, a space or a non-ASCII character; or a host written as an IPv4 address in any form but four decimal numbers, such as `127.1`, `0x7f.1` or `2130706433`. An invalid one raises `base_url_invalid`, so an empty `base_url` argument raises it where an empty `DFO_BASE_URL` counts as unset. A relative URL is invalid, except in a TypeScript client inside a browser page, which resolves a relative base URL against the page's address. A valid value is used as given, less its trailing slashes, so the client's base URL reads `https://developmentfinance.org/api/v1` for `https://developmentfinance.org/api/v1/`; the rule fixes the source and the validity, and leaves the rest of the URL's form to the client. A key sent over plain `http` to a host that is not loopback is refused by `insecure_transport`, which this rule does not decide.
45+
Only the chosen source is checked. A base URL is valid when it is an absolute `http` or `https` URL with a host and none of these: userinfo, an empty one as in `https://@host/api` included; a query; a fragment; a `.` or `..` path segment, with any of its dots written `%2e` or `%2E` included; a backslash; a port that is not 1 to 65535 in decimal digits, an empty port after a `:` included; a character outside visible ASCII, a control character, a space or a non-ASCII character; or a host written as an IPv4 address in any form but four decimal numbers, such as `127.1`, `0x7f.1` or `2130706433`. An invalid one raises `base_url_invalid`, so an empty `base_url` argument raises it where an empty `DFO_BASE_URL` counts as unset. A relative URL is invalid, except in a TypeScript client inside a browser page, which resolves a relative base URL against the page's address. A valid value is used as given, less its trailing slashes, so the client's base URL reads `https://developmentfinance.org/api/v1` for `https://developmentfinance.org/api/v1/`; the rule fixes the source and the validity, and leaves the rest of the URL's form to the client. A key sent over plain `http` to a host that is not loopback is refused by `insecure_transport`, which this rule does not decide.
4646

4747
## Key format (`key-format`)
4848

@@ -93,15 +93,15 @@ The columns come in four groups:
9393
1. the other columns that hold one value in every row, null included, in the order the server wrote them;
9494
1. `stat_var`, last whether or not it varies.
9595

96-
A table with no row or one row has no column that varies. A dataset of several members puts `part` first and judges the groups over the stacked table, where a member's rows hold null in a column that member lacks. So a CRS series of Germany's basic health ODA to Nigeria, split by co-operation modality, reads `time_period`, `financingProvider_label`, `financingRecipient_label`, `crsPurpose_label`, `value`, `unit_label`, `crsTypeOfFlow_label` and `provenance_label`, then `crsCooperationModality` and `crsCooperationModality_label`, which tell its rows apart, then its 15 constant columns, `stat_var` last.
96+
A table with no row or one row has no column that varies. Two cells hold the same value when they are equal as JSON values: numbers by value, so `1` and `1.0` are one value, booleans apart from numbers, so `true` and `1` differ, and null the same as null. NaN, which a Parquet double column can hold, is the same as NaN. A list cell is the same as another when their items are the same, in order, and a struct cell when it has the same fields with the same values, in any field order. A dataset of several members puts `part` first and judges the groups over the stacked table, where a member's rows hold null in a column that member lacks. So a CRS series of Germany's basic health ODA to Nigeria, split by co-operation modality, reads `time_period`, `financingProvider_label`, `financingRecipient_label`, `crsPurpose_label`, `value`, `unit_label`, `crsTypeOfFlow_label` and `provenance_label`, then `crsCooperationModality` and `crsCooperationModality_label`, which tell its rows apart, then its 15 constant columns, `stat_var` last.
9797

98-
The rows of each member are sorted by the filled slots in slot order, leaving out `value`, a measure, and the time period, then by the varying columns in presented order, `stat_var` after them when it varies, then by the time period, so each series' years read together. The keys of that series are the provider, recipient, purpose, unit, flow type and provenance labels, then `crsCooperationModality` and `crsCooperationModality_label`, then the time period, so it reads every A02 year, then every C01 year. Each key sorts ascending, numbers by value and strings by Unicode code point, with nulls last. Rows whose keys tie keep the order the server wrote them. The members follow one another in member order.
98+
The rows of each member are sorted by the filled slots in slot order, leaving out `value`, a measure, and the time period, then by the varying columns in presented order, `stat_var` after them when it varies, then by the time period, so each series' years read together. A column that holds lists or structs is no sort key, since the rule gives them no order. The keys of that series are the provider, recipient, purpose, unit, flow type and provenance labels, then `crsCooperationModality` and `crsCooperationModality_label`, then the time period, so it reads every A02 year, then every C01 year. Each key sorts ascending, numbers by value and strings by Unicode code point, with nulls last. Rows whose keys tie keep the order the server wrote them. The members follow one another in member order.
9999

100-
A vector gives the dataset's `members`, each with its `part` when there are several, its `columns` and its `rows` as the server wrote them, and the `columns` and `rows` a client presents. The order moves columns and rows and changes no value. A client hashes a downloaded file before it orders anything, so the digest and the ref keep the server's file.
100+
A vector gives the dataset's `members`, each with its `part` when there are several, its `columns` and its `rows` as the server wrote them, and the `columns` and `rows` a client presents. Each row holds one cell per column, and the cells of a column, nulls aside, are all strings, all numbers, all booleans, all lists or all structs, since the rule orders no value against one of another type. The order moves columns and rows and changes no value. A client hashes a downloaded file before it orders anything, so the digest and the ref keep the server's file.
101101

102102
## Client identity (`client-identity`)
103103

104-
Every request a client sends to the API carries `X-DFO-Client: <client>/<version> (<runtime>)`. `<client>` is `dfo-python` or `dfo-ts`, and `<version>` is the package version as the package states it. `<runtime>` names the runtime and its major and minor version. In Python it is the implementation as `platform.python_implementation()` names it, then the interpreter's major and minor version, such as `CPython 3.14`. In TypeScript it is `Node`, `Deno` or `Bun` with the major and minor of that runtime's version, such as `Node 22.18`, and `browser` in any other runtime, a page or a worker. `pattern` in `client-identity.json` matches every well-formed value. A vector gives the `client`, the `version`, the `runtime` as its `name` and full `version`, or `browser` with no version, and the `value` the client sends.
104+
Every request a client sends to the API carries `X-DFO-Client: <client>/<version> (<runtime>)`. `<client>` is `dfo-python` or `dfo-ts`, and `<version>` is the package version as the package states it. `<runtime>` names the runtime and its major and minor version. In Python it is the implementation as `platform.python_implementation()` names it, then the interpreter's major and minor version, such as `CPython 3.14`. In TypeScript it is `Node`, `Deno` or `Bun` with the major and minor of that runtime's version, such as `Node 22.18`, `browser` in a browser page or browser worker, and `other` in any other runtime, such as Cloudflare Workers. `pattern` in `client-identity.json` matches every well-formed value. A vector gives the `client`, the `version`, the `runtime` as its `name` and full `version`, or `browser` or `other` with no version, and the `value` the client sends.
105105

106106
An API request is one to the base URL's origin, a `path` or `origin_path` request in a scenario. A request to any other origin, a signed storage URL included, carries no `X-DFO-Client`. In a browser, the files-route request the TypeScript client sends with `redirect: "follow"` carries the API key alone, because the browser forwards every other header to the storage host it is redirected to.
107107

@@ -116,3 +116,7 @@ The roles a spec takes are those its `definition.identity` binds in `entities` o
116116
A client checks each key the caller types before any request. A key outside the spec's roles raises `InvalidRequestError` `invalid_argument`, and the message names the key and the spec's roles. So GDP for Kenya refuses `origin` and `observationAbout` naming `place`, and a flow spec refuses `place` and a column name such as `financingRecipient` naming `origin` and `destination`. A spec that names no role leaves the check to the server, since the client cannot tell which roles it takes. Such a spec posts `place` as `observationAbout` and any other key as typed, and still refuses `observationAbout` naming `place`.
117117

118118
The server replaces a spec's whole binding with the posted one, so a client posts the spec's own `identity.entities` with each role the caller names replaced by its dcids and each role the caller binds to null dropped. A vector gives the spec's `identity` and its `roles`. A vector that narrows adds the caller's `entities`, then either `posted`, the wire entities the build posts, or the `error` with its `reason` and the keys its message `names`, the typed key first.
119+
120+
## File selector (`file-selector`)
121+
122+
The file selector, `GET /datasets/{spec_id}/{content_version}/file`, redirects to a stored version's file in a rendering. A client asks it for a ref member's version when the files route at the stored digest refuses, or when the snapshot manifest lists no artifact of that digest. Its query holds the keys `query_keys` in `file-selector.json` lists, `format`, `bundle` and `layout`, in that order, each one the member's rendering holds, as `key=value` pairs joined by `&`, with booleans written `true` and `false`. The query leaves out `without_party_totals`: the version records the cut it was minted with, and the server lays that over the query. The server refuses any other key with 400 `request_invalid`, so a selector query that carried the cut would end the lookup. A vector gives the `rendering` and the `query` a client sends, without the leading `?`.

‎python/client-contract/src/dfo_client_contract/rules/base-url.json‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,84 @@
172172
"env": {},
173173
"source": "argument",
174174
"url": "https://developmentfinance.org/api/v1"
175+
},
176+
{
177+
"name": "encoded-dot-segment-raises",
178+
"base_url": "https://developmentfinance.org/api/%2e%2e/v1",
179+
"env": {},
180+
"error": {
181+
"reason": "base_url_invalid"
182+
}
183+
},
184+
{
185+
"name": "mixed-encoded-dot-segment-raises",
186+
"base_url": "https://developmentfinance.org/api/.%2E/v1",
187+
"env": {},
188+
"error": {
189+
"reason": "base_url_invalid"
190+
}
191+
},
192+
{
193+
"name": "encoded-single-dot-segment-raises",
194+
"base_url": "https://developmentfinance.org/api/%2E/v1",
195+
"env": {},
196+
"error": {
197+
"reason": "base_url_invalid"
198+
}
199+
},
200+
{
201+
"name": "backslash-raises",
202+
"base_url": "https://developmentfinance.org\\api\\v1",
203+
"env": {},
204+
"error": {
205+
"reason": "base_url_invalid"
206+
}
207+
},
208+
{
209+
"name": "port-out-of-range-raises",
210+
"base_url": "https://developmentfinance.org:99999/api/v1",
211+
"env": {},
212+
"error": {
213+
"reason": "base_url_invalid"
214+
}
215+
},
216+
{
217+
"name": "port-zero-raises",
218+
"base_url": "https://developmentfinance.org:0/api/v1",
219+
"env": {},
220+
"error": {
221+
"reason": "base_url_invalid"
222+
}
223+
},
224+
{
225+
"name": "port-not-decimal-raises",
226+
"base_url": "https://developmentfinance.org:abc/api/v1",
227+
"env": {},
228+
"error": {
229+
"reason": "base_url_invalid"
230+
}
231+
},
232+
{
233+
"name": "empty-port-raises",
234+
"base_url": "https://developmentfinance.org:/api/v1",
235+
"env": {},
236+
"error": {
237+
"reason": "base_url_invalid"
238+
}
239+
},
240+
{
241+
"name": "highest-port-is-valid",
242+
"base_url": "https://developmentfinance.org:65535/api/v1",
243+
"env": {},
244+
"source": "argument",
245+
"url": "https://developmentfinance.org:65535/api/v1"
246+
},
247+
{
248+
"name": "dot-inside-a-segment-is-valid",
249+
"base_url": "https://developmentfinance.org/api/v1.%2e",
250+
"env": {},
251+
"source": "argument",
252+
"url": "https://developmentfinance.org/api/v1.%2e"
175253
}
176254
]
177255
}

‎python/client-contract/src/dfo_client_contract/rules/client-identity.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"format_version": 1,
33
"rule": "client-identity",
44
"header": "X-DFO-Client",
5-
"pattern": "^(dfo-python|dfo-ts)/[0-9A-Za-z.+-]+ \\(([A-Za-z]+ [0-9]+\\.[0-9]+|browser)\\)$",
5+
"pattern": "^(dfo-python|dfo-ts)/[0-9A-Za-z.+-]+ \\(([A-Za-z]+ [0-9]+\\.[0-9]+|browser|other)\\)$",
66
"vectors": [
77
{
88
"name": "python-cpython",
@@ -93,6 +93,15 @@
9393
},
9494
"value": "dfo-ts/0.1.0 (browser)"
9595
},
96+
{
97+
"name": "ts-other-runtime",
98+
"client": "dfo-ts",
99+
"version": "0.1.0",
100+
"runtime": {
101+
"name": "other"
102+
},
103+
"value": "dfo-ts/0.1.0 (other)"
104+
},
96105
{
97106
"name": "ts-pre-release-package",
98107
"client": "dfo-ts",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"format_version": 1,
3+
"rule": "file-selector",
4+
"query_keys": [
5+
"format",
6+
"bundle",
7+
"layout"
8+
],
9+
"vectors": [
10+
{
11+
"name": "long-cut",
12+
"rendering": {
13+
"format": "parquet",
14+
"bundle": false,
15+
"without_party_totals": true
16+
},
17+
"query": "format=parquet&bundle=false"
18+
},
19+
{
20+
"name": "long-plain",
21+
"rendering": {
22+
"format": "parquet",
23+
"bundle": false
24+
},
25+
"query": "format=parquet&bundle=false"
26+
},
27+
{
28+
"name": "frame-cut",
29+
"rendering": {
30+
"format": "parquet",
31+
"bundle": false,
32+
"layout": "frame",
33+
"without_party_totals": true
34+
},
35+
"query": "format=parquet&bundle=false&layout=frame"
36+
},
37+
{
38+
"name": "keys-in-listed-order",
39+
"rendering": {
40+
"layout": "frame",
41+
"bundle": true,
42+
"format": "csv"
43+
},
44+
"query": "format=csv&bundle=true&layout=frame"
45+
}
46+
]
47+
}

‎python/client-contract/src/dfo_client_contract/rules/reading-order.json‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,134 @@
13941394
"dc/sv/gdp"
13951395
]
13961396
]
1397+
},
1398+
{
1399+
"name": "integer-and-float-are-one-value",
1400+
"members": [
1401+
{
1402+
"columns": [
1403+
"time_period",
1404+
"value",
1405+
"currency",
1406+
"base_year"
1407+
],
1408+
"rows": [
1409+
[
1410+
2021,
1411+
5,
1412+
"USD",
1413+
2020
1414+
],
1415+
[
1416+
2020,
1417+
4,
1418+
"USD",
1419+
2020.0
1420+
]
1421+
]
1422+
}
1423+
],
1424+
"columns": [
1425+
"time_period",
1426+
"value",
1427+
"currency",
1428+
"base_year"
1429+
],
1430+
"rows": [
1431+
[
1432+
2020,
1433+
4,
1434+
"USD",
1435+
2020.0
1436+
],
1437+
[
1438+
2021,
1439+
5,
1440+
"USD",
1441+
2020
1442+
]
1443+
]
1444+
},
1445+
{
1446+
"name": "nested-cells-compare-by-value",
1447+
"members": [
1448+
{
1449+
"columns": [
1450+
"time_period",
1451+
"value",
1452+
"meta",
1453+
"tags",
1454+
"flags"
1455+
],
1456+
"rows": [
1457+
[
1458+
2021,
1459+
5,
1460+
{
1461+
"a": 1,
1462+
"b": 2
1463+
},
1464+
[
1465+
1
1466+
],
1467+
[
1468+
true
1469+
]
1470+
],
1471+
[
1472+
2020,
1473+
4,
1474+
{
1475+
"b": 2,
1476+
"a": 1
1477+
},
1478+
[
1479+
1.0
1480+
],
1481+
[
1482+
1
1483+
]
1484+
]
1485+
]
1486+
}
1487+
],
1488+
"columns": [
1489+
"time_period",
1490+
"value",
1491+
"flags",
1492+
"meta",
1493+
"tags"
1494+
],
1495+
"rows": [
1496+
[
1497+
2020,
1498+
4,
1499+
[
1500+
1
1501+
],
1502+
{
1503+
"b": 2,
1504+
"a": 1
1505+
},
1506+
[
1507+
1.0
1508+
]
1509+
],
1510+
[
1511+
2021,
1512+
5,
1513+
[
1514+
true
1515+
],
1516+
{
1517+
"a": 1,
1518+
"b": 2
1519+
},
1520+
[
1521+
1
1522+
]
1523+
]
1524+
]
13971525
}
13981526
]
13991527
}

0 commit comments

Comments
 (0)