fd-open-data-protocol 0.3.1__tar.gz → 0.3.2__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 (33) hide show
  1. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/PKG-INFO +1 -1
  2. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/__init__.py +1 -1
  3. fd_open_data_protocol-0.3.2/fd_open_data_protocol/openalex_provider.py +213 -0
  4. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/concepts.yaml +62 -1
  5. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml +1 -1
  6. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/entity_types.yaml +78 -1
  7. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/event_types.yaml +1 -1
  8. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/qualifiers.yaml +1 -1
  9. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/relation_types.yaml +46 -1
  10. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary/units.yaml +8 -1
  11. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/PKG-INFO +1 -1
  12. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/SOURCES.txt +2 -0
  13. fd_open_data_protocol-0.3.2/tests/test_openalex_provider.py +170 -0
  14. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/README.md +0 -0
  15. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/constants.py +0 -0
  16. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/loader.py +0 -0
  17. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/provider.py +0 -0
  18. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry.py +0 -0
  19. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_cli.py +0 -0
  20. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/registry_store.py +0 -0
  21. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/schema.py +0 -0
  22. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol/vocabulary.py +0 -0
  23. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/dependency_links.txt +0 -0
  24. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/entry_points.txt +0 -0
  25. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/requires.txt +0 -0
  26. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/fd_open_data_protocol.egg-info/top_level.txt +0 -0
  27. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/pyproject.toml +0 -0
  28. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/setup.cfg +0 -0
  29. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_loader.py +0 -0
  30. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_registry_pipeline.py +0 -0
  31. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_registry_schema.py +0 -0
  32. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_schema_vocabulary.py +0 -0
  33. {fd_open_data_protocol-0.3.1 → fd_open_data_protocol-0.3.2}/tests/test_vocabulary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fd-open-data-protocol
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
5
5
  Author: FindDataOfficial
6
6
  License: MIT
@@ -5,4 +5,4 @@ concept hints + fetch reference) so that fd-open-data-mcp - or any consumer -
5
5
  can ingest it via ``register_datasource``. Ship one manifest file -> the
6
6
  datasource is added; no consumer-side wiring.
7
7
  """
8
- __version__ = "0.3.1"
8
+ __version__ = "0.3.2"
@@ -0,0 +1,213 @@
1
+ """OpenAlex 学术图谱数据源 provider(CC0,免费)。
2
+
3
+ 契约要点(对应 academic-graph-connector spec):
4
+ - 礼貌调用:请求带含 mail 的 User-Agent(免费档 1000 credits/次级)
5
+ - 失败显式:配额/限流/不可达分别报错,**禁止返回空结果冒充无数据**
6
+ - 收录偏差:未收录如实标注,不断言「不存在引用」
7
+ - 引用图是增益,不替代既有 papers 检索
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ from typing import Any
17
+
18
+ from .provider import BaseDataProvider
19
+
20
+ API = "https://api.openalex.org"
21
+ DEFAULT_MAILTO = os.environ.get("OPENALEX_MAILTO", "ops@finddata.dev")
22
+ TIMEOUT = float(os.environ.get("OPENALEX_TIMEOUT", "25"))
23
+
24
+ # 礼貌调用:OpenAlex 要求可识别的 User-Agent(含 mail),否则可能被限流
25
+ UA = f"finddata-academic-connector/1.0 (mailto:{DEFAULT_MAILTO})"
26
+
27
+
28
+ class OpenAlexUnavailable(RuntimeError):
29
+ """上游不可用/不可达——显式失败,不静默返回空。"""
30
+
31
+
32
+ class OpenAlexQuotaExceeded(RuntimeError):
33
+ """配额/限流——显式失败,不冒充「无数据」。"""
34
+
35
+
36
+ class OpenAlexProvider(BaseDataProvider):
37
+ name = "academic-openalex"
38
+
39
+ def registry(self) -> dict:
40
+ return {"manifest": "academic_openalex.yaml"}
41
+
42
+ def run(self, command: str, params: dict) -> Any:
43
+ handlers = {
44
+ "get_work": self.get_work,
45
+ "search_works": self.search_works,
46
+ "get_references": self.get_references,
47
+ "get_citations": self.get_citations,
48
+ }
49
+ if command not in handlers:
50
+ raise ValueError(f"unknown command {command!r}")
51
+ return handlers[command](params or {})
52
+
53
+ # ------------------------------------------------------------------ HTTP
54
+
55
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> tuple[dict, dict]:
56
+ """GET 并返回 (payload, quota_headers)。失败显式抛错。"""
57
+ url = f"{API}/{path}"
58
+ if params:
59
+ url += "?" + urllib.parse.urlencode(params)
60
+ req = urllib.request.Request(
61
+ url, headers={"User-Agent": UA, "Accept": "application/json"}
62
+ )
63
+ try:
64
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
65
+ raw = json.loads(resp.read().decode("utf-8", "replace"))
66
+ headers = dict(resp.headers.items())
67
+ except urllib.error.HTTPError as exc:
68
+ # 429 或 5xx 都要区分清楚:前者是配额,后者是上游故障
69
+ if exc.code == 429:
70
+ raise OpenAlexQuotaExceeded(
71
+ f"OpenAlex rate limit / quota exceeded (HTTP 429): {exc.read()[:200]!r}"
72
+ ) from exc
73
+ if 500 <= exc.code < 600:
74
+ raise OpenAlexUnavailable(
75
+ f"OpenAlex upstream unavailable (HTTP {exc.code})"
76
+ ) from exc
77
+ raise OpenAlexUnavailable(
78
+ f"OpenAlex request failed (HTTP {exc.code}): {exc.read()[:200]!r}"
79
+ ) from exc
80
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
81
+ raise OpenAlexUnavailable(f"OpenAlex unreachable: {exc}") from exc
82
+ quota = {
83
+ k.lower(): v
84
+ for k, v in headers.items()
85
+ if k.lower().startswith("x-ratelimit") or k.lower() == "retry-after"
86
+ }
87
+ return raw, quota
88
+
89
+ def _resolve_work_id(self, params: dict) -> str:
90
+ """按 work_id 或 doi 定位作品。两者皆缺则报错(不猜)。"""
91
+ work_id = (params.get("work_id") or "").strip()
92
+ doi = (params.get("doi") or "").strip()
93
+ if not work_id and not doi:
94
+ raise ValueError("get_references/get_citations requires `work_id` or `doi`")
95
+ if work_id:
96
+ # 允许传完整 URL,统一取末段
97
+ return work_id.rstrip("/").split("/")[-1]
98
+ payload, _ = self._get(
99
+ "works",
100
+ {"filter": f"doi:{doi}", "select": "id", "per-page": "1"},
101
+ )
102
+ results = payload.get("results") or []
103
+ if not results:
104
+ raise ValueError(
105
+ f"work not found in OpenAlex for doi={doi!r} "
106
+ f"(未收录,不等于该作品不存在)"
107
+ )
108
+ return str(results[0]["id"]).rstrip("/").split("/")[-1]
109
+
110
+ # ------------------------------------------------------------- commands
111
+
112
+ def get_work(self, params: dict) -> dict:
113
+ doi = (params.get("doi") or "").strip()
114
+ if not doi:
115
+ raise ValueError("get_work requires `doi`")
116
+ payload, quota = self._get(
117
+ "works", {"filter": f"doi:{doi}", "per-page": "1"}
118
+ )
119
+ results = payload.get("results") or []
120
+ if not results:
121
+ # 未收录 ≠ 不存在:如实说明
122
+ raise ValueError(
123
+ f"work not found in OpenAlex for doi={doi!r} (未收录,不等于该作品不存在)"
124
+ )
125
+ w = results[0]
126
+ return {
127
+ "work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
128
+ "title": w.get("title"),
129
+ "year": w.get("publication_year"),
130
+ "venue": (w.get("primary_location") or {}).get("source", {}).get("display_name")
131
+ if isinstance(w.get("primary_location"), dict)
132
+ else None,
133
+ "cited_by_count": w.get("cited_by_count"),
134
+ "doi": w.get("doi"),
135
+ "source": "openalex",
136
+ "license": "CC0",
137
+ "quota": quota,
138
+ }
139
+
140
+ def search_works(self, params: dict) -> dict:
141
+ query = (params.get("query") or "").strip()
142
+ if not query:
143
+ raise ValueError("search_works requires `query`")
144
+ limit = int(params.get("limit") or 10)
145
+ payload, quota = self._get(
146
+ "works",
147
+ {
148
+ "search": query,
149
+ "per-page": str(min(limit, 200)),
150
+ "select": "id,title,publication_year,cited_by_count",
151
+ },
152
+ )
153
+ rows = []
154
+ for w in payload.get("results") or []:
155
+ rows.append(
156
+ {
157
+ "work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
158
+ "title": w.get("title"),
159
+ "year": w.get("publication_year"),
160
+ "cited_by_count": w.get("cited_by_count"),
161
+ }
162
+ )
163
+ return {
164
+ "total": (payload.get("meta") or {}).get("count"),
165
+ "results": rows,
166
+ "source": "openalex",
167
+ "license": "CC0",
168
+ "quota": quota,
169
+ }
170
+
171
+ def get_references(self, params: dict) -> dict:
172
+ work_id = self._resolve_work_id(params)
173
+ payload, quota = self._get(
174
+ f"works/{work_id}", {"select": "id,referenced_works_count,referenced_works"}
175
+ )
176
+ refs = payload.get("referenced_works") or []
177
+ return {
178
+ "work_id": work_id,
179
+ "reference_count": payload.get("referenced_works_count"),
180
+ "reference_ids": [str(r).rstrip("/").split("/")[-1] for r in refs],
181
+ "source": "openalex",
182
+ "license": "CC0",
183
+ "quota": quota,
184
+ }
185
+
186
+ def get_citations(self, params: dict) -> dict:
187
+ work_id = self._resolve_work_id(params)
188
+ limit = int(params.get("limit") or 10)
189
+ payload, quota = self._get(
190
+ "works",
191
+ {
192
+ "filter": f"cites:{work_id}",
193
+ "per-page": str(min(limit, 200)),
194
+ "select": "id,title,publication_year",
195
+ },
196
+ )
197
+ rows = []
198
+ for w in payload.get("results") or []:
199
+ rows.append(
200
+ {
201
+ "citing_work_id": str(w.get("id", "")).rstrip("/").split("/")[-1],
202
+ "title": w.get("title"),
203
+ "year": w.get("publication_year"),
204
+ }
205
+ )
206
+ return {
207
+ "work_id": work_id,
208
+ "total_count": (payload.get("meta") or {}).get("count"),
209
+ "results": rows,
210
+ "source": "openalex",
211
+ "license": "CC0",
212
+ "quota": quota,
213
+ }
@@ -12,7 +12,7 @@
12
12
  # `variables:` block are declarative only (derivation links to them by code).
13
13
  #
14
14
  # Entries are never deleted — retirement is expressed with `superseded_by`.
15
- version: "2026.09"
15
+ version: "2026.11"
16
16
  kind: concept
17
17
  entries:
18
18
  - id: GDP
@@ -228,3 +228,64 @@ entries:
228
228
  unit_type: none
229
229
  dimensions: []
230
230
  variables: []
231
+
232
+ - id: TradeFlow
233
+ label_en: Trade flow value
234
+ label_zh: 贸易流值
235
+ description: >-
236
+ Annual trade flow value (FOB, USD) for a China export commodity at
237
+ HS6 level, split by partner economy. The partner axis is expressed
238
+ encoded in the concept code (trade.export_flow.world/us/de/jp/uk,
239
+ one series per code per the dashboard plane contract); the import
240
+ side joins when an import batch lands. Source: UN Comtrade annual HS flows.
241
+ value_type: currency
242
+ unit_type: currency
243
+ dimensions: [world, us, de, jp, uk]
244
+ variables:
245
+ # 看板取数契约(data_plane.series 只按 code+source+entity 过滤):
246
+ # 伙伴国轴编进 concept code,measure 统一 export——一序列一码。
247
+ - {code: trade.export_flow.world, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
248
+ - {code: trade.export_flow.us, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
249
+ - {code: trade.export_flow.de, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
250
+ - {code: trade.export_flow.jp, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
251
+ - {code: trade.export_flow.uk, entity_type: hs_code, measure: export, unit: usd, frequency: yearly}
252
+
253
+ - id: FxRate
254
+ label_en: FX reference rate
255
+ label_zh: 汇率参考价
256
+ description: >-
257
+ Reference/middle exchange rate for a currency pair, quoted in the
258
+ quote currency per one unit of base (EURCNY = CNY per 1 EUR).
259
+ Sources: ECB EXR monthly reference; PBOC middle rate when it lands.
260
+ value_type: number
261
+ unit_type: currency
262
+ dimensions: [reference]
263
+ variables:
264
+ - {code: fx.rate, entity_type: currency_pair, measure: reference, unit: cny, frequency: monthly}
265
+
266
+ - id: PolicyNotice
267
+ label_en: Policy notice count
268
+ label_zh: 政策通报数
269
+ description: >-
270
+ WTO TBT/SPS notifications raised per notifying member per month
271
+ (WTO ePing feed, deduplicated by member+title+date). For
272
+ cross-border exporters the heat answers "which markets are
273
+ raising barriers this month".
274
+ value_type: number
275
+ unit_type: count
276
+ dimensions: [tbt_sps]
277
+ variables:
278
+ - {code: policy.notice_count, entity_type: country, measure: tbt_sps, unit: count, frequency: monthly}
279
+
280
+ - id: PortCalls
281
+ label_en: Port calls
282
+ label_zh: 港口停靠数
283
+ description: >-
284
+ Daily number of vessel calls at a port (IMF PortWatch). Keyed by
285
+ the portwatch port id; the cross-border port batch registers the
286
+ Chinese hub ports only (never offshore-terminal feeds).
287
+ value_type: number
288
+ unit_type: count
289
+ dimensions: [daily_calls]
290
+ variables:
291
+ - {code: port.calls, entity_type: port, measure: daily_calls, unit: count, frequency: daily}
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # URIs for the mapped Variables themselves are Phase 4; this release carries
12
12
  # only family URIs.
13
- version: "2026.09"
13
+ version: "2026.11"
14
14
  kind: crosswalk
15
15
  mappings:
16
16
  # --- Population -------------------------------------------------------------
@@ -7,7 +7,7 @@
7
7
  # URIs: https://schema.finddata.tech/entity-type/<id>
8
8
  #
9
9
  # Entries are never deleted — retirement is expressed with `superseded_by`.
10
- version: "2026.09"
10
+ version: "2026.11"
11
11
  kind: entity-type
12
12
  entries:
13
13
  - id: country
@@ -97,3 +97,80 @@ entries:
97
97
  description: >-
98
98
  Human entities — fund managers, officers (logical-only; no taxonomy
99
99
  table). Aligns with schema.org Person and the Wikidata QID.
100
+ - id: paper
101
+ label_en: Paper
102
+ label_zh: 论文
103
+ description: >-
104
+ Scholarly works — journal articles, conference papers, preprints.
105
+ Keyed by DOI (10.1038/nature14539) or OpenAlex work id (W2919115771).
106
+ Aligns with schema.org ScholarlyArticle.
107
+ - id: author
108
+ label_en: Author
109
+ label_zh: 作者
110
+ description: >-
111
+ Persons credited as authors of scholarly works. Keyed by OpenAlex
112
+ author id; distinct from `person` (which carries no scholarly role).
113
+ Aligns with schema.org Person.
114
+ - id: institution
115
+ label_en: Institution
116
+ label_zh: 机构
117
+ description: >-
118
+ Research and academic institutions (universities, research institutes,
119
+ hospitals) as affiliation targets. Keyed by OpenAlex institution id;
120
+ aligns with schema.org EducationalOrganization. `organization` covers
121
+ generic bodies; this type is scoped to scholarly affiliation.
122
+ - id: venue
123
+ label_en: Venue
124
+ label_zh: 发表载体
125
+ description: >-
126
+ Publication venues — journals, conference proceedings, repositories.
127
+ Keyed by ISSN or OpenAlex source id. Aligns with schema.org
128
+ Periodical; distinct from `organization` (a venue is not a body).
129
+ - id: topic
130
+ label_en: Topic
131
+ label_zh: 主题
132
+ description: >-
133
+ Scholarly topics / fields of study (OpenAlex topics, concepts).
134
+ Keyed by OpenAlex topic id; describes what a work is about.
135
+ - id: customs_regime
136
+ label_en: Customs regime code
137
+ label_zh: 海关监管代码
138
+ description: >-
139
+ China customs supervision regime codes for cross-border e-commerce
140
+ (9610 retail export, 1210 bonded, 9710 B2B direct export, 9810
141
+ overseas-warehouse export). Keyed by the 4-digit regime code.
142
+ - id: platform
143
+ label_en: E-commerce platform
144
+ label_zh: 电商平台
145
+ description: >-
146
+ Cross-border e-commerce marketplace platforms (amazon, tiktok_shop,
147
+ temu, shein). Keyed by FindData shorthand; marketplace/store detail
148
+ lives in metadata. Merchant-authorized data is keyed here.
149
+ - id: hs_code
150
+ label_en: HS commodity code
151
+ label_zh: HS 商品编码
152
+ description: >-
153
+ Harmonized System commodity classifications keyed by 2/4/6-digit HS
154
+ code (854142). WCO nomenclature; China tariff lines extend to 8-10
155
+ digits on top of the HS6 base.
156
+ - id: currency_pair
157
+ label_en: Currency pair
158
+ label_zh: 货币对
159
+ description: >-
160
+ FX pairs keyed by base+quote ISO codes concatenated (EURCNY). Rate
161
+ concepts carry reference/middle rates per source; value unit is the
162
+ quote currency.
163
+ - id: port
164
+ label_en: Port
165
+ label_zh: 口岸
166
+ description: >-
167
+ Customs ports and logistics gateways keyed by UN/LOCODE or customs
168
+ port code. Reserved for the cross-border port batch (authoritative
169
+ port-code list pending; do not seed from offshore-terminal feeds).
170
+ - id: policy
171
+ label_en: Policy entry
172
+ label_zh: 政策条目
173
+ description: >-
174
+ Individual policy/instrument entries (customs announcements, tax
175
+ notices) keyed by issuing-body document id. Registered incrementally
176
+ as policy patrol output lands.
@@ -12,6 +12,6 @@
12
12
  # URIs: https://schema.finddata.tech/event-type/<id>
13
13
  #
14
14
  # Entries are never deleted — retirement is expressed with `superseded_by`.
15
- version: "2026.09"
15
+ version: "2026.11"
16
16
  kind: event-type
17
17
  entries: []
@@ -8,7 +8,7 @@
8
8
  # URIs: https://schema.finddata.tech/qualifier/<id>
9
9
  #
10
10
  # Entries are never deleted — retirement is expressed with `superseded_by`.
11
- version: "2026.09"
11
+ version: "2026.11"
12
12
  kind: qualifier
13
13
  entries:
14
14
  - id: nominal_current
@@ -9,7 +9,7 @@
9
9
  # URIs: https://schema.finddata.tech/relation-type/<id>
10
10
  #
11
11
  # Entries are never deleted — retirement is expressed with `superseded_by`.
12
- version: "2026.09"
12
+ version: "2026.11"
13
13
  kind: relation-type
14
14
  entries:
15
15
  - id: belongs_to
@@ -71,3 +71,48 @@ entries:
71
71
  The instrument was issued by the entity (stock/bond/fund -> company).
72
72
  source_types: [stock, bond, fund]
73
73
  target_types: [company, organization]
74
+ - id: cites
75
+ label_en: Cites
76
+ label_zh: 引用
77
+ description: >-
78
+ The source work cites the target work in its bibliography
79
+ (paper -> paper). Directional: source references target.
80
+ source_types: [paper]
81
+ target_types: [paper]
82
+ - id: cited_by
83
+ label_en: Cited By
84
+ label_zh: 被引
85
+ description: >-
86
+ Inverse of `cites` — the source work is cited by the target work
87
+ (paper -> paper). Retained explicitly because external scholarly
88
+ APIs expose both directions as distinct queries.
89
+ source_types: [paper]
90
+ target_types: [paper]
91
+ - id: authored_by
92
+ label_en: Authored By
93
+ label_zh: 作者为
94
+ description: >-
95
+ The work was authored by the person (paper -> author).
96
+ source_types: [paper]
97
+ target_types: [author]
98
+ - id: affiliated_with
99
+ label_en: Affiliated With
100
+ label_zh: 隶属于
101
+ description: >-
102
+ The author is affiliated with the institution (author -> institution).
103
+ source_types: [author]
104
+ target_types: [institution]
105
+ - id: published_in
106
+ label_en: Published In
107
+ label_zh: 发表于
108
+ description: >-
109
+ The work was published in the venue (paper -> venue).
110
+ source_types: [paper]
111
+ target_types: [venue]
112
+ - id: has_topic
113
+ label_en: Has Topic
114
+ label_zh: 主题为
115
+ description: >-
116
+ The work is classified under the topic (paper -> topic).
117
+ source_types: [paper]
118
+ target_types: [topic]
@@ -8,7 +8,7 @@
8
8
  # URIs: https://schema.finddata.tech/unit/<id>
9
9
  #
10
10
  # Entries are never deleted — retirement is expressed with `superseded_by`.
11
- version: "2026.09"
11
+ version: "2026.11"
12
12
  kind: unit
13
13
  entries:
14
14
  # --- Monetary ---------------------------------------------------------------
@@ -92,3 +92,10 @@ entries:
92
92
  label_zh: 年
93
93
  description: A duration measured in calendar years.
94
94
  un_cefact_code: ANN
95
+
96
+ - id: count
97
+ label_en: Count
98
+ label_zh: 计数
99
+ description: >-
100
+ Dimensionless tally of discrete events or items (notifications, port
101
+ calls). Used where the value is a number of things, not a measurement.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fd-open-data-protocol
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: The open-data datasource protocol: a manifest contract a datasource exposes to be ingested by fd-open-data-mcp (or any consumer).
5
5
  Author: FindDataOfficial
6
6
  License: MIT
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  fd_open_data_protocol/__init__.py
4
4
  fd_open_data_protocol/constants.py
5
5
  fd_open_data_protocol/loader.py
6
+ fd_open_data_protocol/openalex_provider.py
6
7
  fd_open_data_protocol/provider.py
7
8
  fd_open_data_protocol/registry.py
8
9
  fd_open_data_protocol/registry_cli.py
@@ -23,6 +24,7 @@ fd_open_data_protocol/vocabulary/relation_types.yaml
23
24
  fd_open_data_protocol/vocabulary/units.yaml
24
25
  fd_open_data_protocol/vocabulary/crosswalks/datacommons-worldbank.yaml
25
26
  tests/test_loader.py
27
+ tests/test_openalex_provider.py
26
28
  tests/test_registry_pipeline.py
27
29
  tests/test_registry_schema.py
28
30
  tests/test_schema_vocabulary.py
@@ -0,0 +1,170 @@
1
+ """OpenAlex provider:离线测试(不访问网络)。
2
+
3
+ 覆盖契约要点:礼貌调用、四项命令、失败显式(配额/限流/不可达)、
4
+ 收录偏差如实标注、CC0 归属。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import urllib.error
9
+
10
+ import pytest
11
+
12
+ from fd_open_data_protocol import openalex_provider as oa
13
+
14
+
15
+ class FakeResp:
16
+ def __init__(self, headers=None):
17
+ self.headers = headers or {}
18
+
19
+ def read(self):
20
+ return b"boom"
21
+
22
+ def __enter__(self):
23
+ return self
24
+
25
+ def __exit__(self, *a):
26
+ return False
27
+
28
+ def close(self):
29
+ return None
30
+
31
+ def items(self):
32
+ return list(self.headers.items())
33
+
34
+
35
+ def test_polite_user_agent_carries_mailto():
36
+ assert "mailto:" in oa.UA
37
+ assert "@" in oa.UA.split("mailto:")[-1].strip(")")
38
+
39
+
40
+ def test_quota_headers_recorded(monkeypatch):
41
+ payload = {
42
+ "results": [
43
+ {
44
+ "id": "https://openalex.org/W1",
45
+ "title": "T",
46
+ "publication_year": 2020,
47
+ "cited_by_count": 5,
48
+ "doi": "10.1/x",
49
+ }
50
+ ]
51
+ }
52
+
53
+ def fake_urlopen(req, timeout=None):
54
+ assert req.headers.get("User-agent") == oa.UA or req.get_header("User-agent") == oa.UA
55
+ return _json_resp(payload, {"x-ratelimit-limit": "1000"})
56
+
57
+ monkeypatch.setattr(oa.urllib.request, "urlopen", fake_urlopen)
58
+ out = oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
59
+ assert out["quota"]["x-ratelimit-limit"] == "1000"
60
+ assert out["source"] == "openalex" and out["license"] == "CC0"
61
+
62
+
63
+ def _json_resp(payload, headers=None):
64
+ class R:
65
+ def __init__(self):
66
+ self.headers = _H(headers or {})
67
+
68
+ def read(self):
69
+ return __import__("json").dumps(payload).encode()
70
+
71
+ def __enter__(self):
72
+ return self
73
+
74
+ def __exit__(self, *a):
75
+ return False
76
+
77
+ class _H(dict):
78
+ def items(self):
79
+ return list(dict.items(self))
80
+
81
+ return R()
82
+
83
+
84
+ def test_search_works_returns_total_and_rows(monkeypatch):
85
+ payload = {
86
+ "meta": {"count": 42},
87
+ "results": [
88
+ {"id": "https://openalex.org/Wa", "title": "A", "publication_year": 2017,
89
+ "cited_by_count": 7},
90
+ {"id": "https://openalex.org/Wb", "title": "B", "publication_year": 2019,
91
+ "cited_by_count": 9},
92
+ ],
93
+ }
94
+ monkeypatch.setattr(oa.urllib.request, "urlopen",
95
+ lambda req, timeout=None: _json_resp(payload))
96
+ out = oa.OpenAlexProvider().run("search_works", {"query": "q", "limit": 2})
97
+ assert out["total"] == 42 and len(out["results"]) == 2
98
+ assert out["results"][0]["work_id"] == "Wa"
99
+
100
+
101
+ def test_references_and_citations(monkeypatch):
102
+ p = oa.OpenAlexProvider()
103
+ monkeypatch.setattr(oa.urllib.request, "urlopen", lambda req, timeout=None: _json_resp(
104
+ {"id": "https://openalex.org/W1", "referenced_works_count": 2,
105
+ "referenced_works": ["https://openalex.org/Wx", "https://openalex.org/Wy"]}))
106
+ r = p.run("get_references", {"work_id": "W1"})
107
+ assert r["reference_count"] == 2 and r["reference_ids"] == ["Wx", "Wy"]
108
+
109
+ monkeypatch.setattr(oa.urllib.request, "urlopen", lambda req, timeout=None: _json_resp(
110
+ {"meta": {"count": 100},
111
+ "results": [{"id": "https://openalex.org/Wc", "title": "C", "publication_year": 2021}]}))
112
+ c = p.run("get_citations", {"work_id": "W1", "limit": 1})
113
+ assert c["total_count"] == 100 and c["results"][0]["citing_work_id"] == "Wc"
114
+
115
+
116
+ def test_quota_error_is_explicit(monkeypatch):
117
+ def raise_429(req, timeout=None):
118
+ raise urllib.error.HTTPError("u", 429, "Too Many Requests", {}, FakeResp())
119
+
120
+ monkeypatch.setattr(oa.urllib.request, "urlopen", raise_429)
121
+ with pytest.raises(oa.OpenAlexQuotaExceeded):
122
+ oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
123
+
124
+
125
+ def test_upstream_5xx_is_explicit(monkeypatch):
126
+ def raise_503(req, timeout=None):
127
+ raise urllib.error.HTTPError("u", 503, "Unavailable", {}, FakeResp())
128
+
129
+ monkeypatch.setattr(oa.urllib.request, "urlopen", raise_503)
130
+ with pytest.raises(oa.OpenAlexUnavailable):
131
+ oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
132
+
133
+
134
+ def test_unreachable_is_explicit(monkeypatch):
135
+ def raise_urlerr(req, timeout=None):
136
+ raise urllib.error.URLError("dns fail")
137
+
138
+ monkeypatch.setattr(oa.urllib.request, "urlopen", raise_urlerr)
139
+ with pytest.raises(oa.OpenAlexUnavailable):
140
+ oa.OpenAlexProvider().run("get_work", {"doi": "10.1/x"})
141
+
142
+
143
+ def test_not_found_states_not_indexed_not_nonexistent(monkeypatch):
144
+ """未收录必须如实说明——不断言「不存在」。"""
145
+ monkeypatch.setattr(oa.urllib.request, "urlopen",
146
+ lambda req, timeout=None: _json_resp({"results": []}))
147
+ with pytest.raises(ValueError, match="未收录"):
148
+ oa.OpenAlexProvider().run("get_work", {"doi": "10.1/nope"})
149
+
150
+
151
+ def test_missing_identifier_rejected():
152
+ p = oa.OpenAlexProvider()
153
+ with pytest.raises(ValueError):
154
+ p.run("get_references", {})
155
+ with pytest.raises(ValueError):
156
+ p.run("get_work", {})
157
+
158
+
159
+ def test_manifest_validates_and_declares_relationships():
160
+ from fd_open_data_protocol.loader import load_catalog
161
+
162
+ m = load_catalog("adapters/academic_openalex.yaml")
163
+ cmds = {f.command for f in m.functions}
164
+ assert {"get_work", "search_works", "get_references", "get_citations"} <= cmds
165
+ rels = {(r.relation_type, r.source_entity_type, r.target_entity_type)
166
+ for r in (m.relationships or [])}
167
+ assert ("cites", "paper", "paper") in rels
168
+ assert ("cited_by", "paper", "paper") in rels
169
+ ents = {e.entity_type for e in (m.entities or [])}
170
+ assert {"paper", "author", "venue", "topic", "institution"} <= ents