axdata 0.1.0__py3-none-any.whl

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 (427) hide show
  1. axdata/__init__.py +16 -0
  2. axdata/cache.py +19 -0
  3. axdata/client.py +1223 -0
  4. axdata-0.1.0.dist-info/METADATA +238 -0
  5. axdata-0.1.0.dist-info/RECORD +427 -0
  6. axdata-0.1.0.dist-info/WHEEL +5 -0
  7. axdata-0.1.0.dist-info/entry_points.txt +8 -0
  8. axdata-0.1.0.dist-info/licenses/LICENSE +162 -0
  9. axdata-0.1.0.dist-info/top_level.txt +6 -0
  10. axdata_core/__init__.py +221 -0
  11. axdata_core/_tdx_wire/__init__.py +35 -0
  12. axdata_core/_tdx_wire/_shim.py +140 -0
  13. axdata_core/_tdx_wire/api/__init__.py +51 -0
  14. axdata_core/_tdx_wire/api/auction.py +24 -0
  15. axdata_core/_tdx_wire/api/bars.py +24 -0
  16. axdata_core/_tdx_wire/api/base.py +26 -0
  17. axdata_core/_tdx_wire/api/codes.py +25 -0
  18. axdata_core/_tdx_wire/api/corporate.py +24 -0
  19. axdata_core/_tdx_wire/api/finance.py +24 -0
  20. axdata_core/_tdx_wire/api/intraday.py +24 -0
  21. axdata_core/_tdx_wire/api/quotes.py +26 -0
  22. axdata_core/_tdx_wire/api/resources.py +25 -0
  23. axdata_core/_tdx_wire/api/session.py +24 -0
  24. axdata_core/_tdx_wire/api/trades.py +24 -0
  25. axdata_core/_tdx_wire/client.py +33 -0
  26. axdata_core/_tdx_wire/exceptions.py +27 -0
  27. axdata_core/_tdx_wire/hosts.py +106 -0
  28. axdata_core/_tdx_wire/models/__init__.py +93 -0
  29. axdata_core/_tdx_wire/models/auction.py +26 -0
  30. axdata_core/_tdx_wire/models/corporate.py +27 -0
  31. axdata_core/_tdx_wire/models/finance.py +26 -0
  32. axdata_core/_tdx_wire/models/intraday.py +31 -0
  33. axdata_core/_tdx_wire/models/kline.py +27 -0
  34. axdata_core/_tdx_wire/models/quote.py +32 -0
  35. axdata_core/_tdx_wire/models/resource.py +24 -0
  36. axdata_core/_tdx_wire/models/security.py +24 -0
  37. axdata_core/_tdx_wire/models/session.py +27 -0
  38. axdata_core/_tdx_wire/models/subchart.py +25 -0
  39. axdata_core/_tdx_wire/models/trade.py +28 -0
  40. axdata_core/_tdx_wire/protocol/__init__.py +49 -0
  41. axdata_core/_tdx_wire/protocol/commands/__init__.py +43 -0
  42. axdata_core/_tdx_wire/protocol/commands/auction.py +110 -0
  43. axdata_core/_tdx_wire/protocol/commands/codec.py +43 -0
  44. axdata_core/_tdx_wire/protocol/commands/corporate.py +98 -0
  45. axdata_core/_tdx_wire/protocol/commands/finance.py +96 -0
  46. axdata_core/_tdx_wire/protocol/commands/intraday.py +122 -0
  47. axdata_core/_tdx_wire/protocol/commands/klines.py +102 -0
  48. axdata_core/_tdx_wire/protocol/commands/lookup.py +36 -0
  49. axdata_core/_tdx_wire/protocol/commands/price_limits.py +89 -0
  50. axdata_core/_tdx_wire/protocol/commands/quotes.py +102 -0
  51. axdata_core/_tdx_wire/protocol/commands/registry.py +36 -0
  52. axdata_core/_tdx_wire/protocol/commands/resources.py +99 -0
  53. axdata_core/_tdx_wire/protocol/commands/security.py +103 -0
  54. axdata_core/_tdx_wire/protocol/commands/session.py +82 -0
  55. axdata_core/_tdx_wire/protocol/commands/subchart.py +97 -0
  56. axdata_core/_tdx_wire/protocol/commands/trades.py +107 -0
  57. axdata_core/_tdx_wire/protocol/constants.py +124 -0
  58. axdata_core/_tdx_wire/protocol/frame.py +68 -0
  59. axdata_core/_tdx_wire/protocol/unit.py +85 -0
  60. axdata_core/_tdx_wire/py.typed +1 -0
  61. axdata_core/_tdx_wire/transport/__init__.py +34 -0
  62. axdata_core/_tdx_wire/transport/base.py +25 -0
  63. axdata_core/_tdx_wire/transport/memory.py +24 -0
  64. axdata_core/_tdx_wire/transport/pool.py +32 -0
  65. axdata_core/_tdx_wire/transport/socket.py +39 -0
  66. axdata_core/adapters/__init__.py +0 -0
  67. axdata_core/adapters/cls/__init__.py +17 -0
  68. axdata_core/adapters/cls/provider_bridge.py +23 -0
  69. axdata_core/adapters/cls/request.py +632 -0
  70. axdata_core/adapters/cls/source_adapter_registry.py +31 -0
  71. axdata_core/adapters/cninfo/__init__.py +17 -0
  72. axdata_core/adapters/cninfo/provider_bridge.py +25 -0
  73. axdata_core/adapters/cninfo/request.py +2425 -0
  74. axdata_core/adapters/cninfo/source_adapter_registry.py +42 -0
  75. axdata_core/adapters/eastmoney/__init__.py +17 -0
  76. axdata_core/adapters/eastmoney/provider_bridge.py +25 -0
  77. axdata_core/adapters/eastmoney/request.py +1128 -0
  78. axdata_core/adapters/eastmoney/source_adapter_registry.py +42 -0
  79. axdata_core/adapters/exchange/__init__.py +17 -0
  80. axdata_core/adapters/exchange/provider_bridge.py +25 -0
  81. axdata_core/adapters/exchange/request.py +1214 -0
  82. axdata_core/adapters/exchange/source_adapter_registry.py +42 -0
  83. axdata_core/adapters/kph/__init__.py +17 -0
  84. axdata_core/adapters/kph/provider_bridge.py +23 -0
  85. axdata_core/adapters/kph/request.py +509 -0
  86. axdata_core/adapters/kph/source_adapter_registry.py +31 -0
  87. axdata_core/adapters/sina/__init__.py +17 -0
  88. axdata_core/adapters/sina/provider_bridge.py +25 -0
  89. axdata_core/adapters/sina/request.py +5155 -0
  90. axdata_core/adapters/sina/source_adapter_registry.py +42 -0
  91. axdata_core/adapters/tdx/__init__.py +32 -0
  92. axdata_core/adapters/tdx/adjustment.py +50 -0
  93. axdata_core/adapters/tdx/adjustment_fetch.py +51 -0
  94. axdata_core/adapters/tdx/auction_fetch.py +51 -0
  95. axdata_core/adapters/tdx/client_factory.py +110 -0
  96. axdata_core/adapters/tdx/code_fetch.py +62 -0
  97. axdata_core/adapters/tdx/codes.py +54 -0
  98. axdata_core/adapters/tdx/derived_rows.py +69 -0
  99. axdata_core/adapters/tdx/downloader.py +193 -0
  100. axdata_core/adapters/tdx/downloader_interface_sets.py +50 -0
  101. axdata_core/adapters/tdx/downloader_profiles.py +43 -0
  102. axdata_core/adapters/tdx/downloader_registry.py +115 -0
  103. axdata_core/adapters/tdx/execution_utils.py +55 -0
  104. axdata_core/adapters/tdx/f10_executor.py +57 -0
  105. axdata_core/adapters/tdx/f10_normalize.py +61 -0
  106. axdata_core/adapters/tdx/f10_params.py +57 -0
  107. axdata_core/adapters/tdx/f10_postprocess.py +67 -0
  108. axdata_core/adapters/tdx/f10_render.py +48 -0
  109. axdata_core/adapters/tdx/f10_request.py +62 -0
  110. axdata_core/adapters/tdx/finance_fetch.py +61 -0
  111. axdata_core/adapters/tdx/finance_maps.py +54 -0
  112. axdata_core/adapters/tdx/finance_normalize.py +57 -0
  113. axdata_core/adapters/tdx/host_config.py +115 -0
  114. axdata_core/adapters/tdx/interface_sets.py +50 -0
  115. axdata_core/adapters/tdx/intraday_fetch.py +68 -0
  116. axdata_core/adapters/tdx/kline_helpers.py +58 -0
  117. axdata_core/adapters/tdx/limit_ladder_fetch.py +60 -0
  118. axdata_core/adapters/tdx/limit_ladder_topics.py +53 -0
  119. axdata_core/adapters/tdx/normalize_utils.py +75 -0
  120. axdata_core/adapters/tdx/options.py +61 -0
  121. axdata_core/adapters/tdx/price_limit_calendar.py +49 -0
  122. axdata_core/adapters/tdx/price_limit_fetch.py +53 -0
  123. axdata_core/adapters/tdx/price_limit_history.py +50 -0
  124. axdata_core/adapters/tdx/price_limits.py +58 -0
  125. axdata_core/adapters/tdx/provider_bridge.py +68 -0
  126. axdata_core/adapters/tdx/quote_fetch.py +61 -0
  127. axdata_core/adapters/tdx/quote_identity.py +49 -0
  128. axdata_core/adapters/tdx/rank_fetch.py +55 -0
  129. axdata_core/adapters/tdx/rank_params.py +52 -0
  130. axdata_core/adapters/tdx/realtime_refresh.py +45 -0
  131. axdata_core/adapters/tdx/request.py +11 -0
  132. axdata_core/adapters/tdx/request_adapter_runtime.py +41 -0
  133. axdata_core/adapters/tdx/request_client.py +51 -0
  134. axdata_core/adapters/tdx/request_compat.py +89 -0
  135. axdata_core/adapters/tdx/request_dispatch.py +50 -0
  136. axdata_core/adapters/tdx/request_filters.py +60 -0
  137. axdata_core/adapters/tdx/request_host_config.py +110 -0
  138. axdata_core/adapters/tdx/request_limits.py +59 -0
  139. axdata_core/adapters/tdx/request_methods.py +41 -0
  140. axdata_core/adapters/tdx/request_params.py +62 -0
  141. axdata_core/adapters/tdx/request_seams.py +41 -0
  142. axdata_core/adapters/tdx/resources/__init__.py +0 -0
  143. axdata_core/adapters/tdx/resources/finance_maps/__init__.py +0 -0
  144. axdata_core/adapters/tdx/resources/finance_maps/incon.dat +3702 -0
  145. axdata_core/adapters/tdx/resources/finance_maps/tdxhy.cfg +5620 -0
  146. axdata_core/adapters/tdx/resources/finance_maps/tdxzs.cfg +605 -0
  147. axdata_core/adapters/tdx/security_codes.py +54 -0
  148. axdata_core/adapters/tdx/series_history.py +57 -0
  149. axdata_core/adapters/tdx/server_cache.py +58 -0
  150. axdata_core/adapters/tdx/snapshot_normalize.py +56 -0
  151. axdata_core/adapters/tdx/source_adapter_registry.py +69 -0
  152. axdata_core/adapters/tdx/source_execution.py +44 -0
  153. axdata_core/adapters/tdx/source_execution_registry.py +70 -0
  154. axdata_core/adapters/tdx/stats_cache.py +184 -0
  155. axdata_core/adapters/tdx/stats_models.py +159 -0
  156. axdata_core/adapters/tdx/stats_resource.py +304 -0
  157. axdata_core/adapters/tdx/status_fetch.py +55 -0
  158. axdata_core/adapters/tdx/time_series_normalize.py +67 -0
  159. axdata_core/adapters/tdx/tqlex.py +50 -0
  160. axdata_core/adapters/tdx/wire_requests.py +60 -0
  161. axdata_core/adapters/tdx_ext/__init__.py +66 -0
  162. axdata_core/adapters/tdx_ext/client.py +688 -0
  163. axdata_core/adapters/tdx_ext/exceptions.py +51 -0
  164. axdata_core/adapters/tdx_ext/host_config.py +40 -0
  165. axdata_core/adapters/tdx_ext/interface_sets.py +65 -0
  166. axdata_core/adapters/tdx_ext/local_cache.py +649 -0
  167. axdata_core/adapters/tdx_ext/models.py +96 -0
  168. axdata_core/adapters/tdx_ext/options.py +36 -0
  169. axdata_core/adapters/tdx_ext/pool.py +153 -0
  170. axdata_core/adapters/tdx_ext/provider_bridge.py +35 -0
  171. axdata_core/adapters/tdx_ext/request.py +498 -0
  172. axdata_core/adapters/tdx_ext/request_execution.py +118 -0
  173. axdata_core/adapters/tdx_ext/request_instruments.py +221 -0
  174. axdata_core/adapters/tdx_ext/request_normalize.py +356 -0
  175. axdata_core/adapters/tdx_ext/request_params.py +157 -0
  176. axdata_core/adapters/tdx_ext/request_series.py +98 -0
  177. axdata_core/adapters/tdx_ext/server_cache.py +13 -0
  178. axdata_core/adapters/tdx_ext/servers.py +35 -0
  179. axdata_core/adapters/tdx_ext/source_adapter_registry.py +42 -0
  180. axdata_core/adapters/tdx_ext/source_execution.py +19 -0
  181. axdata_core/adapters/tdx_ext/source_execution_registry.py +43 -0
  182. axdata_core/adapters/tencent/__init__.py +17 -0
  183. axdata_core/adapters/tencent/provider_bridge.py +25 -0
  184. axdata_core/adapters/tencent/request.py +729 -0
  185. axdata_core/adapters/tencent/source_adapter_registry.py +42 -0
  186. axdata_core/axp.py +1843 -0
  187. axdata_core/builtin_providers.py +586 -0
  188. axdata_core/builtin_source_declarations.py +156 -0
  189. axdata_core/cli.py +1818 -0
  190. axdata_core/collector_registry.py +227 -0
  191. axdata_core/collector_runner.py +1016 -0
  192. axdata_core/collector_scheduler.py +3155 -0
  193. axdata_core/collector_templates.py +339 -0
  194. axdata_core/data_browser.py +1696 -0
  195. axdata_core/diagnostics.py +734 -0
  196. axdata_core/downloader_engine.py +1480 -0
  197. axdata_core/downloader_registry.py +62 -0
  198. axdata_core/downloaders.py +1619 -0
  199. axdata_core/legacy_provider_adapter.py +68 -0
  200. axdata_core/paths.py +51 -0
  201. axdata_core/plugin_config.py +272 -0
  202. axdata_core/plugin_status.py +419 -0
  203. axdata_core/plugins.py +1363 -0
  204. axdata_core/provider_catalog.py +462 -0
  205. axdata_core/provider_registry.py +833 -0
  206. axdata_core/quality.py +86 -0
  207. axdata_core/query.py +242 -0
  208. axdata_core/resources/__init__.py +2 -0
  209. axdata_core/resources/tdx_extended_servers.json +22 -0
  210. axdata_core/resources/tdx_quote_servers.json +49 -0
  211. axdata_core/sample_collectors.py +42 -0
  212. axdata_core/schema.py +385 -0
  213. axdata_core/source_adapter_factory.py +95 -0
  214. axdata_core/source_adapter_options.py +18 -0
  215. axdata_core/source_errors.py +43 -0
  216. axdata_core/source_execution_options.py +37 -0
  217. axdata_core/source_execution_registry.py +27 -0
  218. axdata_core/source_projection.py +21 -0
  219. axdata_core/source_request.py +441 -0
  220. axdata_core/source_session.py +355 -0
  221. axdata_core/sources/__init__.py +40 -0
  222. axdata_core/sources/base.py +112 -0
  223. axdata_core/sources/catalog.py +60 -0
  224. axdata_core/sources/cls/__init__.py +5 -0
  225. axdata_core/sources/cls/catalog.py +231 -0
  226. axdata_core/sources/cninfo/__init__.py +5 -0
  227. axdata_core/sources/cninfo/catalog.py +1994 -0
  228. axdata_core/sources/display_docs.py +139 -0
  229. axdata_core/sources/eastmoney/__init__.py +5 -0
  230. axdata_core/sources/eastmoney/catalog.py +509 -0
  231. axdata_core/sources/exchange/__init__.py +5 -0
  232. axdata_core/sources/exchange/catalog.py +361 -0
  233. axdata_core/sources/kph/__init__.py +5 -0
  234. axdata_core/sources/kph/catalog.py +255 -0
  235. axdata_core/sources/sina/__init__.py +5 -0
  236. axdata_core/sources/sina/catalog.py +2951 -0
  237. axdata_core/sources/tdx/__init__.py +5 -0
  238. axdata_core/sources/tdx/catalog.py +4546 -0
  239. axdata_core/sources/tdx_ext/__init__.py +5 -0
  240. axdata_core/sources/tdx_ext/catalog.py +1518 -0
  241. axdata_core/sources/tencent/__init__.py +5 -0
  242. axdata_core/sources/tencent/catalog.py +370 -0
  243. axdata_core/storage.py +186 -0
  244. axdata_core/tdx_f10_catalog.py +52 -0
  245. axdata_core/tdx_f10_models.py +50 -0
  246. axdata_core/tdx_f10_names.py +47 -0
  247. axdata_core/tdx_f10_specs.py +56 -0
  248. axdata_core/tdx_plugin_required.py +43 -0
  249. axdata_core/tdx_server_config.py +65 -0
  250. axdata_core/trade_calendar_cache.py +397 -0
  251. axdata_source_cninfo/__init__.py +15 -0
  252. axdata_source_cninfo/adapter.py +31 -0
  253. axdata_source_cninfo/axdata-provider.json +384 -0
  254. axdata_source_cninfo/catalog.py +209 -0
  255. axdata_source_cninfo/metadata.py +8 -0
  256. axdata_source_cninfo/provider.py +41 -0
  257. axdata_source_tdx/__init__.py +15 -0
  258. axdata_source_tdx/_tdx_wire/__init__.py +26 -0
  259. axdata_source_tdx/_tdx_wire/_binary.py +218 -0
  260. axdata_source_tdx/_tdx_wire/_code_utils.py +42 -0
  261. axdata_source_tdx/_tdx_wire/_command_codec.py +186 -0
  262. axdata_source_tdx/_tdx_wire/_command_codes.py +74 -0
  263. axdata_source_tdx/_tdx_wire/_command_defaults.py +15 -0
  264. axdata_source_tdx/_tdx_wire/_command_dispatch.py +96 -0
  265. axdata_source_tdx/_tdx_wire/_command_layouts.py +96 -0
  266. axdata_source_tdx/_tdx_wire/_command_lookup.py +25 -0
  267. axdata_source_tdx/_tdx_wire/_command_metadata.py +32 -0
  268. axdata_source_tdx/_tdx_wire/_command_registry.py +61 -0
  269. axdata_source_tdx/_tdx_wire/_connection_defaults.py +13 -0
  270. axdata_source_tdx/_tdx_wire/_host_defaults.py +51 -0
  271. axdata_source_tdx/_tdx_wire/_host_probe.py +81 -0
  272. axdata_source_tdx/_tdx_wire/_host_resource.py +70 -0
  273. axdata_source_tdx/_tdx_wire/_host_utils.py +31 -0
  274. axdata_source_tdx/_tdx_wire/_market.py +66 -0
  275. axdata_source_tdx/_tdx_wire/_request_defaults.py +11 -0
  276. axdata_source_tdx/_tdx_wire/_security_classification.py +61 -0
  277. axdata_source_tdx/_tdx_wire/_time_utils.py +7 -0
  278. axdata_source_tdx/_tdx_wire/api/__init__.py +45 -0
  279. axdata_source_tdx/_tdx_wire/api/auction.py +25 -0
  280. axdata_source_tdx/_tdx_wire/api/bars.py +31 -0
  281. axdata_source_tdx/_tdx_wire/api/base.py +39 -0
  282. axdata_source_tdx/_tdx_wire/api/codes.py +25 -0
  283. axdata_source_tdx/_tdx_wire/api/corporate.py +11 -0
  284. axdata_source_tdx/_tdx_wire/api/finance.py +10 -0
  285. axdata_source_tdx/_tdx_wire/api/intraday.py +38 -0
  286. axdata_source_tdx/_tdx_wire/api/quotes.py +78 -0
  287. axdata_source_tdx/_tdx_wire/api/resources.py +34 -0
  288. axdata_source_tdx/_tdx_wire/api/session.py +16 -0
  289. axdata_source_tdx/_tdx_wire/api/trades.py +34 -0
  290. axdata_source_tdx/_tdx_wire/client.py +470 -0
  291. axdata_source_tdx/_tdx_wire/exceptions.py +25 -0
  292. axdata_source_tdx/_tdx_wire/hosts.py +27 -0
  293. axdata_source_tdx/_tdx_wire/models/__init__.py +87 -0
  294. axdata_source_tdx/_tdx_wire/models/auction.py +58 -0
  295. axdata_source_tdx/_tdx_wire/models/corporate.py +59 -0
  296. axdata_source_tdx/_tdx_wire/models/finance.py +80 -0
  297. axdata_source_tdx/_tdx_wire/models/intraday.py +112 -0
  298. axdata_source_tdx/_tdx_wire/models/kline.py +58 -0
  299. axdata_source_tdx/_tdx_wire/models/quote.py +262 -0
  300. axdata_source_tdx/_tdx_wire/models/resource.py +18 -0
  301. axdata_source_tdx/_tdx_wire/models/security.py +28 -0
  302. axdata_source_tdx/_tdx_wire/models/session.py +30 -0
  303. axdata_source_tdx/_tdx_wire/models/subchart.py +39 -0
  304. axdata_source_tdx/_tdx_wire/models/trade.py +46 -0
  305. axdata_source_tdx/_tdx_wire/protocol/__init__.py +43 -0
  306. axdata_source_tdx/_tdx_wire/protocol/_frame_constants.py +5 -0
  307. axdata_source_tdx/_tdx_wire/protocol/commands/__init__.py +37 -0
  308. axdata_source_tdx/_tdx_wire/protocol/commands/auction.py +165 -0
  309. axdata_source_tdx/_tdx_wire/protocol/commands/codec.py +37 -0
  310. axdata_source_tdx/_tdx_wire/protocol/commands/corporate.py +163 -0
  311. axdata_source_tdx/_tdx_wire/protocol/commands/finance.py +180 -0
  312. axdata_source_tdx/_tdx_wire/protocol/commands/intraday.py +382 -0
  313. axdata_source_tdx/_tdx_wire/protocol/commands/klines.py +396 -0
  314. axdata_source_tdx/_tdx_wire/protocol/commands/lookup.py +31 -0
  315. axdata_source_tdx/_tdx_wire/protocol/commands/price_limits.py +110 -0
  316. axdata_source_tdx/_tdx_wire/protocol/commands/quotes.py +804 -0
  317. axdata_source_tdx/_tdx_wire/protocol/commands/registry.py +30 -0
  318. axdata_source_tdx/_tdx_wire/protocol/commands/resources.py +107 -0
  319. axdata_source_tdx/_tdx_wire/protocol/commands/security.py +163 -0
  320. axdata_source_tdx/_tdx_wire/protocol/commands/session.py +129 -0
  321. axdata_source_tdx/_tdx_wire/protocol/commands/subchart.py +182 -0
  322. axdata_source_tdx/_tdx_wire/protocol/commands/trades.py +275 -0
  323. axdata_source_tdx/_tdx_wire/protocol/constants.py +70 -0
  324. axdata_source_tdx/_tdx_wire/protocol/frame.py +141 -0
  325. axdata_source_tdx/_tdx_wire/protocol/unit.py +98 -0
  326. axdata_source_tdx/_tdx_wire/py.typed +1 -0
  327. axdata_source_tdx/_tdx_wire/transport/__init__.py +28 -0
  328. axdata_source_tdx/_tdx_wire/transport/base.py +21 -0
  329. axdata_source_tdx/_tdx_wire/transport/memory.py +42 -0
  330. axdata_source_tdx/_tdx_wire/transport/pool.py +148 -0
  331. axdata_source_tdx/_tdx_wire/transport/socket.py +373 -0
  332. axdata_source_tdx/adapter.py +31 -0
  333. axdata_source_tdx/adjustment.py +163 -0
  334. axdata_source_tdx/adjustment_fetch.py +148 -0
  335. axdata_source_tdx/auction_fetch.py +162 -0
  336. axdata_source_tdx/axdata-provider.json +23680 -0
  337. axdata_source_tdx/catalog.py +39 -0
  338. axdata_source_tdx/client_factory.py +61 -0
  339. axdata_source_tdx/code_fetch.py +579 -0
  340. axdata_source_tdx/codes.py +69 -0
  341. axdata_source_tdx/collectors.py +525 -0
  342. axdata_source_tdx/derived_rows.py +500 -0
  343. axdata_source_tdx/downloader.py +139 -0
  344. axdata_source_tdx/downloader_interface_sets.py +29 -0
  345. axdata_source_tdx/downloader_profiles.py +731 -0
  346. axdata_source_tdx/downloader_registry.py +85 -0
  347. axdata_source_tdx/execution_utils.py +141 -0
  348. axdata_source_tdx/f10_executor.py +414 -0
  349. axdata_source_tdx/f10_normalize.py +169 -0
  350. axdata_source_tdx/f10_params.py +221 -0
  351. axdata_source_tdx/f10_postprocess.py +405 -0
  352. axdata_source_tdx/f10_render.py +29 -0
  353. axdata_source_tdx/f10_request.py +365 -0
  354. axdata_source_tdx/finance_fetch.py +514 -0
  355. axdata_source_tdx/finance_maps.py +377 -0
  356. axdata_source_tdx/finance_normalize.py +273 -0
  357. axdata_source_tdx/host_config.py +51 -0
  358. axdata_source_tdx/interface_sets.py +180 -0
  359. axdata_source_tdx/intraday_fetch.py +600 -0
  360. axdata_source_tdx/kline_helpers.py +333 -0
  361. axdata_source_tdx/limit_ladder_fetch.py +614 -0
  362. axdata_source_tdx/limit_ladder_topics.py +280 -0
  363. axdata_source_tdx/metadata.py +8 -0
  364. axdata_source_tdx/normalize_utils.py +264 -0
  365. axdata_source_tdx/options.py +195 -0
  366. axdata_source_tdx/price_limit_calendar.py +106 -0
  367. axdata_source_tdx/price_limit_fetch.py +264 -0
  368. axdata_source_tdx/price_limit_history.py +100 -0
  369. axdata_source_tdx/price_limits.py +142 -0
  370. axdata_source_tdx/provider.py +44 -0
  371. axdata_source_tdx/provider_bridge.py +43 -0
  372. axdata_source_tdx/quote_fetch.py +485 -0
  373. axdata_source_tdx/quote_identity.py +50 -0
  374. axdata_source_tdx/rank_fetch.py +316 -0
  375. axdata_source_tdx/rank_params.py +181 -0
  376. axdata_source_tdx/realtime_refresh.py +51 -0
  377. axdata_source_tdx/request_adapter.py +533 -0
  378. axdata_source_tdx/request_adapter_runtime.py +302 -0
  379. axdata_source_tdx/request_client.py +98 -0
  380. axdata_source_tdx/request_compat.py +432 -0
  381. axdata_source_tdx/request_dispatch.py +138 -0
  382. axdata_source_tdx/request_entrypoints.py +146 -0
  383. axdata_source_tdx/request_filters.py +154 -0
  384. axdata_source_tdx/request_host_config.py +103 -0
  385. axdata_source_tdx/request_limits.py +15 -0
  386. axdata_source_tdx/request_methods.py +1910 -0
  387. axdata_source_tdx/request_params.py +225 -0
  388. axdata_source_tdx/request_seams.py +534 -0
  389. axdata_source_tdx/resources/__init__.py +0 -0
  390. axdata_source_tdx/resources/finance_maps/__init__.py +1 -0
  391. axdata_source_tdx/resources/finance_maps/incon.dat +3702 -0
  392. axdata_source_tdx/resources/finance_maps/tdxhy.cfg +5620 -0
  393. axdata_source_tdx/resources/finance_maps/tdxzs.cfg +605 -0
  394. axdata_source_tdx/resources/tdx_extended_servers.json +22 -0
  395. axdata_source_tdx/resources/tdx_quote_servers.json +49 -0
  396. axdata_source_tdx/security_codes.py +127 -0
  397. axdata_source_tdx/series_history.py +348 -0
  398. axdata_source_tdx/server_cache.py +21 -0
  399. axdata_source_tdx/snapshot_normalize.py +232 -0
  400. axdata_source_tdx/source_adapter_registry.py +42 -0
  401. axdata_source_tdx/source_execution.py +22 -0
  402. axdata_source_tdx/source_execution_registry.py +43 -0
  403. axdata_source_tdx/stats_cache.py +150 -0
  404. axdata_source_tdx/stats_models.py +161 -0
  405. axdata_source_tdx/stats_resource.py +242 -0
  406. axdata_source_tdx/status_fetch.py +209 -0
  407. axdata_source_tdx/tdx_f10_catalog.py +957 -0
  408. axdata_source_tdx/tdx_f10_models.py +47 -0
  409. axdata_source_tdx/tdx_f10_names.py +48 -0
  410. axdata_source_tdx/tdx_f10_specs.py +1142 -0
  411. axdata_source_tdx/tdx_server_config.py +389 -0
  412. axdata_source_tdx/time_series_normalize.py +370 -0
  413. axdata_source_tdx/tqlex.py +138 -0
  414. axdata_source_tdx/wire.py +32 -0
  415. axdata_source_tdx/wire_requests.py +190 -0
  416. axdata_source_tdx_ext/__init__.py +15 -0
  417. axdata_source_tdx_ext/adapter.py +31 -0
  418. axdata_source_tdx_ext/axdata-provider.json +9424 -0
  419. axdata_source_tdx_ext/catalog.py +33 -0
  420. axdata_source_tdx_ext/metadata.py +8 -0
  421. axdata_source_tdx_ext/provider.py +44 -0
  422. axdata_source_tencent/__init__.py +15 -0
  423. axdata_source_tencent/adapter.py +31 -0
  424. axdata_source_tencent/axdata-provider.json +291 -0
  425. axdata_source_tencent/catalog.py +190 -0
  426. axdata_source_tencent/metadata.py +8 -0
  427. axdata_source_tencent/provider.py +41 -0
@@ -0,0 +1,1696 @@
1
+ """Local dataset discovery and small preview queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ from dataclasses import dataclass, field
10
+ from datetime import date, datetime
11
+ from pathlib import Path
12
+ from collections.abc import Iterable, Mapping, Sequence
13
+ from typing import Any
14
+
15
+ from .collector_registry import build_collector_registry
16
+ from .collector_scheduler import CollectorSchedulerStore, collector_scheduler_store_path
17
+ from .schema import Field, TableSchema, get_schema, list_tables
18
+ from .storage import core_table_path, core_table_partition_path
19
+
20
+ DEFAULT_PREVIEW_LIMIT = 3
21
+ MAX_PREVIEW_LIMIT = 100
22
+ MAX_DISCOVERY_RUNS = 500
23
+ MAX_DOWNLOADER_LOGS_PER_DIR = 200
24
+ MAX_MISSING_PATHS = 20
25
+ MAX_PARQUET_STATS_FILES = 200
26
+ MAX_PARQUET_STATS_DIRS = 2000
27
+ KNOWN_DATA_LAYERS = frozenset({"raw", "staging", "core", "factor", "snapshot", "snapshots"})
28
+ DATASET_FORMAT_DIRS = frozenset({"parquet", "csv", "duckdb", "jsonl", "logs"})
29
+ _NO_MATCHING_PARQUET_PARTITIONS = object()
30
+
31
+
32
+ class DataBrowserError(ValueError):
33
+ """Raised when local dataset discovery or preview cannot continue."""
34
+
35
+
36
+ @dataclass
37
+ class DatasetSummary:
38
+ """A locally discovered dataset and its user-facing metadata."""
39
+
40
+ dataset: str
41
+ interface_name: str
42
+ display_name_zh: str | None = None
43
+ description: str = ""
44
+ provider: str | None = None
45
+ source: str | None = None
46
+ layer: str | None = None
47
+ output_paths: dict[str, str] = field(default_factory=dict)
48
+ row_count: int | None = None
49
+ date_min: str | None = None
50
+ date_max: str | None = None
51
+ datetime_min: str | None = None
52
+ datetime_max: str | None = None
53
+ columns: list[str] = field(default_factory=list)
54
+ quality_status: str | None = None
55
+ quality_warnings: list[str] = field(default_factory=list)
56
+ quality_errors: list[str] = field(default_factory=list)
57
+ quality: dict[str, Any] = field(default_factory=dict)
58
+ latest_run_id: str | None = None
59
+ latest_run_status: str | None = None
60
+ updated_at: str | None = None
61
+ missing_paths: list[str] = field(default_factory=list)
62
+ metadata: dict[str, Any] = field(default_factory=dict)
63
+ write_mode: str | None = None
64
+ partition_by: list[str] = field(default_factory=list)
65
+ primary_key: list[str] = field(default_factory=list)
66
+ date_field: str | None = None
67
+ replace_range_start: str | None = None
68
+ replace_range_end: str | None = None
69
+ rows_before: int | None = None
70
+ rows_written: int | None = None
71
+ rows_after: int | None = None
72
+ duplicate_rows_dropped: int | None = None
73
+ partitions_touched: list[str] = field(default_factory=list)
74
+ field_schema: list[dict[str, Any]] = field(default_factory=list)
75
+ logical_table: str | None = None
76
+ storage_layout: str | None = None
77
+ default_query_fields: list[str] = field(default_factory=list)
78
+ default_filter_fields: list[str] = field(default_factory=list)
79
+ available_formats: list[str] = field(default_factory=list)
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ return {
83
+ "dataset": self.dataset,
84
+ "interface_name": self.interface_name,
85
+ "display_name_zh": self.display_name_zh,
86
+ "description": self.description,
87
+ "provider": self.provider,
88
+ "source": self.source,
89
+ "layer": self.layer,
90
+ "output_paths": dict(sorted(self.output_paths.items())),
91
+ "row_count": self.row_count,
92
+ "date_min": self.date_min,
93
+ "date_max": self.date_max,
94
+ "datetime_min": self.datetime_min,
95
+ "datetime_max": self.datetime_max,
96
+ "columns": list(self.columns),
97
+ "quality_status": self.quality_status,
98
+ "quality_warnings": list(self.quality_warnings),
99
+ "quality_errors": list(self.quality_errors),
100
+ "quality": _jsonable(self.quality),
101
+ "latest_run_id": self.latest_run_id,
102
+ "latest_run_status": self.latest_run_status,
103
+ "updated_at": self.updated_at,
104
+ "missing_paths": list(self.missing_paths),
105
+ "metadata": _jsonable(self.metadata),
106
+ "write_mode": self.write_mode,
107
+ "partition_by": list(self.partition_by),
108
+ "primary_key": list(self.primary_key),
109
+ "date_field": self.date_field,
110
+ "replace_range_start": self.replace_range_start,
111
+ "replace_range_end": self.replace_range_end,
112
+ "rows_before": self.rows_before,
113
+ "rows_written": self.rows_written,
114
+ "rows_after": self.rows_after,
115
+ "duplicate_rows_dropped": self.duplicate_rows_dropped,
116
+ "partitions_touched": list(self.partitions_touched),
117
+ "field_schema": _jsonable(self.field_schema),
118
+ "logical_table": self.logical_table,
119
+ "storage_layout": self.storage_layout,
120
+ "default_query_fields": list(self.default_query_fields),
121
+ "default_filter_fields": list(self.default_filter_fields),
122
+ "available_formats": list(self.available_formats),
123
+ }
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class DataPreview:
128
+ """Small filtered preview result for one dataset."""
129
+
130
+ dataset: DatasetSummary
131
+ rows: list[dict[str, Any]]
132
+ limit: int
133
+ filters: dict[str, Any]
134
+ columns: list[str]
135
+ preview_format: str = "parquet"
136
+ preview_paths: list[str] = field(default_factory=list)
137
+
138
+ def to_dict(self) -> dict[str, Any]:
139
+ return {
140
+ "dataset": self.dataset.to_dict(),
141
+ "rows": _jsonable(self.rows),
142
+ "limit": self.limit,
143
+ "filters": _jsonable(self.filters),
144
+ "columns": list(self.columns),
145
+ "preview_format": self.preview_format,
146
+ "preview_paths": list(self.preview_paths),
147
+ "count": len(self.rows),
148
+ }
149
+
150
+
151
+ def list_datasets(
152
+ *,
153
+ data_root: str | Path | None = None,
154
+ include_core: bool = True,
155
+ ) -> list[DatasetSummary]:
156
+ """Discover local datasets from run metadata and known core parquet paths."""
157
+
158
+ root = _resolve_data_root(data_root)
159
+ entries: dict[str, DatasetSummary] = {}
160
+
161
+ for summary in _declared_dataset_summaries(root):
162
+ _merge_summary(entries, summary)
163
+
164
+ for run in _load_collector_runs(root):
165
+ summary = _summary_from_run(root, run)
166
+ if summary is None:
167
+ continue
168
+ _merge_summary(entries, summary)
169
+
170
+ for log_payload in _iter_downloader_logs(root, entries):
171
+ summary = _summary_from_log(root, log_payload)
172
+ if summary is None:
173
+ continue
174
+ _merge_summary(entries, summary)
175
+
176
+ if include_core:
177
+ for table in list_tables():
178
+ summary = _summary_from_core_table(root, table)
179
+ if summary is not None:
180
+ _merge_summary(entries, summary)
181
+
182
+ local_entries = [
183
+ summary
184
+ for summary in entries.values()
185
+ if _has_local_output_reference(summary)
186
+ ]
187
+ return sorted(
188
+ local_entries,
189
+ key=lambda item: (
190
+ item.updated_at is not None,
191
+ item.updated_at or "",
192
+ item.dataset,
193
+ ),
194
+ reverse=True,
195
+ )
196
+
197
+
198
+ def get_dataset(
199
+ dataset: str,
200
+ *,
201
+ data_root: str | Path | None = None,
202
+ ) -> DatasetSummary:
203
+ """Return one discovered dataset by dataset name or interface name."""
204
+
205
+ normalized = _normalize_dataset_name(dataset)
206
+ candidates = list_datasets(data_root=data_root)
207
+ for summary in candidates:
208
+ if _normalize_dataset_name(summary.dataset) == normalized:
209
+ return summary
210
+ for summary in candidates:
211
+ if _normalize_dataset_name(summary.interface_name) == normalized:
212
+ return summary
213
+ known = ", ".join(item.dataset for item in candidates) or "<empty>"
214
+ raise DataBrowserError(f"Dataset {dataset!r} was not found. Known datasets: {known}.")
215
+
216
+
217
+ def preview_dataset(
218
+ dataset: str,
219
+ *,
220
+ data_root: str | Path | None = None,
221
+ fields: Sequence[str] | str | None = None,
222
+ filters: Mapping[str, Any] | None = None,
223
+ symbol: str | None = None,
224
+ start: str | None = None,
225
+ end: str | None = None,
226
+ limit: int | None = DEFAULT_PREVIEW_LIMIT,
227
+ ) -> DataPreview:
228
+ """Preview at most 100 rows from one local dataset."""
229
+
230
+ summary = get_dataset(dataset, data_root=data_root)
231
+ if summary.missing_paths:
232
+ missing = ", ".join(summary.missing_paths)
233
+ raise FileNotFoundError(
234
+ f"Dataset {summary.dataset!r} has stale output path metadata. Missing path(s): {missing}."
235
+ )
236
+ parquet_paths = _existing_parquet_paths(summary.output_paths)
237
+ if not parquet_paths:
238
+ raise FileNotFoundError(
239
+ f"Dataset {summary.dataset!r} has no existing parquet output. "
240
+ "Run a Collector/Downloader first, or choose a dataset with parquet output."
241
+ )
242
+
243
+ normalized_limit = clamp_limit(limit)
244
+ query_filters = _normalize_preview_filters(summary, filters, symbol)
245
+ start_date = _normalize_date_text(start)
246
+ end_date = _normalize_date_text(end)
247
+ selected_fields = _normalize_fields(fields)
248
+ rows, columns = _query_parquet(
249
+ parquet_paths,
250
+ fields=selected_fields,
251
+ filters=query_filters,
252
+ date_field=_date_field(summary),
253
+ start=start_date,
254
+ end=end_date,
255
+ limit=normalized_limit,
256
+ known_columns=summary.columns,
257
+ )
258
+ return DataPreview(
259
+ dataset=summary,
260
+ rows=rows,
261
+ limit=normalized_limit,
262
+ filters={**query_filters, **_date_filter_payload(start_date, end_date)},
263
+ columns=columns,
264
+ preview_format="parquet",
265
+ preview_paths=[str(path) for path in parquet_paths],
266
+ )
267
+
268
+
269
+ def delete_dataset(
270
+ dataset: str,
271
+ *,
272
+ data_root: str | Path | None = None,
273
+ ) -> dict[str, Any]:
274
+ """Delete one local dataset directory and stale run/log references."""
275
+
276
+ root = _resolve_data_root(data_root)
277
+ summary = get_dataset(dataset, data_root=root)
278
+ safe_paths = _safe_dataset_delete_paths(summary, root)
279
+ deleted_paths: list[str] = []
280
+ missing_paths: list[str] = []
281
+ for path in safe_paths:
282
+ if not path.exists():
283
+ missing_paths.append(str(path))
284
+ continue
285
+ if path.is_dir():
286
+ shutil.rmtree(path)
287
+ else:
288
+ path.unlink()
289
+ deleted_paths.append(str(path))
290
+
291
+ deleted_logs = _delete_dataset_log_files(summary, root)
292
+ deleted_runs = _delete_dataset_runs(summary, root, safe_paths)
293
+
294
+ if not deleted_paths and not deleted_logs and not deleted_runs:
295
+ raise DataBrowserError(f"Dataset {summary.dataset!r} has no AxData-owned local output to delete.")
296
+
297
+ return {
298
+ "dataset": summary.to_dict(),
299
+ "deleted_paths": deleted_paths,
300
+ "missing_paths": missing_paths,
301
+ "deleted_logs": deleted_logs,
302
+ "deleted_runs": deleted_runs,
303
+ }
304
+
305
+
306
+ def _safe_dataset_delete_paths(summary: DatasetSummary, root: Path) -> list[Path]:
307
+ paths: list[Path] = []
308
+ seen: set[Path] = set()
309
+ for path_text in summary.output_paths.values():
310
+ path = _safe_axdata_data_path(path_text, root)
311
+ path = _dataset_delete_root_for_path(path, summary, root)
312
+ if path not in seen:
313
+ seen.add(path)
314
+ paths.append(path)
315
+ if not paths:
316
+ raise DataBrowserError(f"Dataset {summary.dataset!r} has no local output path.")
317
+ return paths
318
+
319
+
320
+ def _dataset_delete_root_for_path(path: Path, summary: DatasetSummary, root: Path) -> Path:
321
+ root_resolved = root.resolve()
322
+ names = _dataset_path_names(summary)
323
+ for layer_root in _allowed_dataset_roots(root_resolved):
324
+ if not _is_relative_to(path, layer_root):
325
+ continue
326
+ relative = path.relative_to(layer_root)
327
+ parts = relative.parts
328
+ if not parts:
329
+ break
330
+ for index, part in enumerate(parts):
331
+ if _dataset_component_matches(part, names):
332
+ return _safe_axdata_data_path(str(layer_root.joinpath(*parts[: index + 1])), root)
333
+ if layer_root.name == "core" and parts[0].startswith("table="):
334
+ return _safe_axdata_data_path(str(layer_root / parts[0]), root)
335
+ if len(parts) == 1:
336
+ return _safe_axdata_data_path(str(path), root)
337
+ if len(parts) > 1 and parts[1] in DATASET_FORMAT_DIRS:
338
+ return _safe_axdata_data_path(str(layer_root / parts[0]), root)
339
+ if len(parts) > 2 and parts[2] in DATASET_FORMAT_DIRS:
340
+ return _safe_axdata_data_path(str(layer_root / parts[0] / parts[1]), root)
341
+ return _safe_axdata_data_path(str(layer_root / parts[0]), root)
342
+ return path
343
+
344
+
345
+ def _dataset_path_names(summary: DatasetSummary) -> set[str]:
346
+ names = {
347
+ _normalize_path_component(summary.dataset),
348
+ _normalize_path_component(summary.interface_name),
349
+ }
350
+ names.discard("")
351
+ expanded = set(names)
352
+ for name in names:
353
+ expanded.add(f"table={name}")
354
+ expanded.add(f"interface={name}")
355
+ if "." in name:
356
+ expanded.add(name.rsplit(".", 1)[-1])
357
+ return expanded
358
+
359
+
360
+ def _normalize_path_component(value: str | None) -> str:
361
+ return str(value or "").strip().lower().replace("\\", "/")
362
+
363
+
364
+ def _dataset_component_matches(component: str, names: set[str]) -> bool:
365
+ text = _normalize_path_component(component)
366
+ if text in names:
367
+ return True
368
+ if "=" in text:
369
+ key, value = text.split("=", 1)
370
+ return value in names or f"{key}={value}" in names
371
+ return False
372
+
373
+
374
+ def _safe_axdata_data_path(path_text: str, root: Path) -> Path:
375
+ if not str(path_text).strip():
376
+ raise DataBrowserError("Dataset output path is empty.")
377
+ root_resolved = root.resolve()
378
+ path = Path(path_text).expanduser()
379
+ if not path.is_absolute():
380
+ path = root_resolved / path
381
+ resolved = path.resolve()
382
+ if resolved == root_resolved or not _is_relative_to(resolved, root_resolved):
383
+ raise DataBrowserError(f"Refusing to delete path outside AxData data directory: {resolved}")
384
+ allowed_roots = _allowed_dataset_roots(root_resolved)
385
+ if not any(resolved != allowed and _is_relative_to(resolved, allowed) for allowed in allowed_roots):
386
+ raise DataBrowserError(f"Refusing to delete non-dataset path: {resolved}")
387
+ return resolved
388
+
389
+
390
+ def _allowed_dataset_roots(root_resolved: Path) -> list[Path]:
391
+ return [
392
+ root_resolved / "raw",
393
+ root_resolved / "staging",
394
+ root_resolved / "core",
395
+ root_resolved / "factor",
396
+ root_resolved / "snapshot",
397
+ root_resolved / "snapshots",
398
+ ]
399
+
400
+
401
+ def _delete_dataset_log_files(summary: DatasetSummary, root: Path) -> list[str]:
402
+ deleted: list[str] = []
403
+ log_path = summary.metadata.get("log_path") if isinstance(summary.metadata, Mapping) else None
404
+ if not isinstance(log_path, str) or not log_path.strip():
405
+ return deleted
406
+ try:
407
+ path = _safe_axdata_data_path(log_path, root)
408
+ except DataBrowserError:
409
+ return deleted
410
+ if path.is_file() and path.suffix.lower() == ".json":
411
+ path.unlink()
412
+ deleted.append(str(path))
413
+ return deleted
414
+
415
+
416
+ def _delete_dataset_runs(summary: DatasetSummary, root: Path, deleted_targets: Sequence[Path]) -> list[str]:
417
+ store_path = collector_scheduler_store_path(data_root=root)
418
+ if not store_path.exists():
419
+ return []
420
+ targets = tuple(path.resolve() for path in deleted_targets)
421
+ store = CollectorSchedulerStore(data_root=root)
422
+ run_ids: list[str] = []
423
+ for run in store.list_runs():
424
+ run_paths = _safe_run_output_paths(run, root)
425
+ if any(_paths_overlap(run_path, target) for run_path in run_paths for target in targets):
426
+ run_ids.append(str(run.run_id))
427
+ return [run.run_id for run in store.delete_runs(run_ids)]
428
+
429
+
430
+ def _safe_run_output_paths(run: Any, root: Path) -> set[Path]:
431
+ output_paths: dict[str, Any] = {}
432
+ raw_output_paths = getattr(run, "output_paths", None)
433
+ if isinstance(raw_output_paths, Mapping):
434
+ output_paths.update(raw_output_paths)
435
+ result = getattr(run, "result", None)
436
+ if isinstance(result, Mapping):
437
+ download_result = result.get("download_result")
438
+ if isinstance(download_result, Mapping) and isinstance(download_result.get("output_paths"), Mapping):
439
+ output_paths.update(download_result["output_paths"])
440
+ paths: set[Path] = set()
441
+ for path_text in output_paths.values():
442
+ if not isinstance(path_text, str):
443
+ continue
444
+ try:
445
+ paths.add(_safe_axdata_data_path(path_text, root))
446
+ except DataBrowserError:
447
+ continue
448
+ return paths
449
+
450
+
451
+ def _paths_overlap(left: Path, right: Path) -> bool:
452
+ if left == right:
453
+ return True
454
+ return _is_relative_to(left, right) or _is_relative_to(right, left)
455
+
456
+
457
+ def _is_relative_to(path: Path, parent: Path) -> bool:
458
+ try:
459
+ path.relative_to(parent)
460
+ except ValueError:
461
+ return False
462
+ return True
463
+
464
+
465
+ def clamp_limit(limit: int | None, *, default: int = DEFAULT_PREVIEW_LIMIT, maximum: int = MAX_PREVIEW_LIMIT) -> int:
466
+ if limit is None:
467
+ return default
468
+ try:
469
+ value = int(limit)
470
+ except (TypeError, ValueError) as exc:
471
+ raise DataBrowserError("limit must be an integer.") from exc
472
+ if value <= 0:
473
+ raise DataBrowserError("limit must be positive.")
474
+ return min(value, maximum)
475
+
476
+
477
+ def _resolve_data_root(data_root: str | Path | None) -> Path:
478
+ return Path(data_root or os.getenv("AXDATA_DATA_DIR", "data")).expanduser().resolve()
479
+
480
+
481
+ def _load_collector_runs(root: Path) -> tuple[Any, ...]:
482
+ store_path = collector_scheduler_store_path(data_root=root)
483
+ if not store_path.exists():
484
+ return ()
485
+ try:
486
+ return CollectorSchedulerStore(data_root=root).list_runs(limit=MAX_DISCOVERY_RUNS)
487
+ except Exception:
488
+ return ()
489
+
490
+
491
+ def _declared_dataset_summaries(root: Path) -> Iterable[DatasetSummary]:
492
+ try:
493
+ registry = build_collector_registry(data_root=root)
494
+ except Exception:
495
+ return ()
496
+ summaries: list[DatasetSummary] = []
497
+ for registration in registry.list_collectors():
498
+ collector = registration.collector
499
+ output = dict(collector.output or {})
500
+ for declaration in _collector_output_declarations(collector, output):
501
+ dataset = str(declaration.get("dataset_id") or collector.dataset_id or collector.collector_id)
502
+ layer = _string_or_none(declaration.get("layer") or output.get("layer") or output.get("output_layer"))
503
+ declared_paths = _declared_output_paths(root, declaration, output)
504
+ actual_paths = {
505
+ key: value
506
+ for key, value in declared_paths.items()
507
+ if Path(value).exists()
508
+ }
509
+ summary = DatasetSummary(
510
+ dataset=dataset,
511
+ interface_name=str(declaration.get("table") or declaration.get("logical_table") or dataset),
512
+ display_name_zh=_string_or_none(declaration.get("display_name_zh")) or collector.display_name_zh,
513
+ description=str(declaration.get("description") or collector.description or ""),
514
+ provider=registration.collector_plugin_id,
515
+ source=_string_or_none(declaration.get("source") or registration.collector_plugin_id),
516
+ layer=layer,
517
+ output_paths=actual_paths,
518
+ columns=_string_list(declaration.get("columns") or declaration.get("default_query_fields")),
519
+ metadata={
520
+ "collector_name": collector.collector_id,
521
+ "collector_plugin_id": registration.collector_plugin_id,
522
+ "declared_only": True,
523
+ "expected_output_paths": declared_paths,
524
+ },
525
+ write_mode=_string_or_none(declaration.get("write_mode") or output.get("write_mode")),
526
+ partition_by=_string_list(declaration.get("partition_by") or output.get("partition_by")),
527
+ primary_key=_string_list(declaration.get("primary_key") or output.get("primary_key")),
528
+ date_field=_string_or_none(declaration.get("date_field") or output.get("date_field")),
529
+ )
530
+ _apply_dataset_declaration(summary, declaration, root=root)
531
+ summaries.append(_enrich_summary_from_paths(summary, root=root))
532
+ return summaries
533
+
534
+
535
+ def _has_local_output_reference(summary: DatasetSummary) -> bool:
536
+ """Return whether a dataset represents local files or stale local metadata."""
537
+
538
+ return bool(summary.output_paths)
539
+
540
+
541
+ def _collector_output_declarations(collector: Any, output: Mapping[str, Any]) -> list[dict[str, Any]]:
542
+ raw_datasets = output.get("datasets") or output.get("outputs")
543
+ declarations: list[dict[str, Any]] = []
544
+ if isinstance(raw_datasets, Sequence) and not isinstance(raw_datasets, (str, bytes, bytearray)):
545
+ for item in raw_datasets:
546
+ if isinstance(item, Mapping):
547
+ declarations.append(dict(item))
548
+ if declarations:
549
+ return declarations
550
+
551
+ dataset_id = getattr(collector, "dataset_id", None)
552
+ if not dataset_id:
553
+ return []
554
+ return [
555
+ {
556
+ "dataset_id": dataset_id,
557
+ "display_name_zh": getattr(collector, "display_name_zh", None),
558
+ "description": getattr(collector, "description", ""),
559
+ "layer": output.get("layer") or output.get("output_layer"),
560
+ "table": output.get("table") or output.get("logical_table") or dataset_id,
561
+ "fields": output.get("fields"),
562
+ "primary_key": output.get("primary_key"),
563
+ "date_field": output.get("date_field"),
564
+ "partition_by": output.get("partition_by"),
565
+ "write_mode": output.get("write_mode"),
566
+ "storage": output.get("storage"),
567
+ "formats": output.get("supported_formats") or output.get("formats"),
568
+ }
569
+ ]
570
+
571
+
572
+ def _declared_output_paths(root: Path, declaration: Mapping[str, Any], output: Mapping[str, Any]) -> dict[str, str]:
573
+ path_parts = _string_list(
574
+ declaration.get("default_output_path_parts")
575
+ or declaration.get("path_parts")
576
+ or output.get("default_output_path_parts")
577
+ )
578
+ if not path_parts:
579
+ layer = str(declaration.get("layer") or output.get("layer") or output.get("output_layer") or "snapshot")
580
+ table = str(declaration.get("table") or declaration.get("logical_table") or declaration.get("dataset_id") or "")
581
+ default_dir_name = str(
582
+ declaration.get("default_dir_name")
583
+ or output.get("default_dir_name")
584
+ or declaration.get("dataset_id")
585
+ or table
586
+ )
587
+ path_parts = [layer, f"table={table}" if layer == "core" and table and "." not in table else default_dir_name]
588
+ base = root.joinpath(*path_parts)
589
+ formats = _declared_formats(declaration, output)
590
+ return {format_name: str(base / format_name) for format_name in formats}
591
+
592
+
593
+ def _output_dataset_declaration(
594
+ payload: Mapping[str, Any],
595
+ *,
596
+ interface_name: str,
597
+ layer: str | None,
598
+ ) -> dict[str, Any]:
599
+ output = payload.get("output")
600
+ if not isinstance(output, Mapping):
601
+ output = {}
602
+ raw_datasets = output.get("datasets") or output.get("outputs")
603
+ if isinstance(raw_datasets, Sequence) and not isinstance(raw_datasets, (str, bytes, bytearray)):
604
+ target_names = {
605
+ _normalize_dataset_name(interface_name),
606
+ _normalize_dataset_name(str(payload.get("dataset_id") or "")),
607
+ _normalize_dataset_name(str(payload.get("table") or "")),
608
+ }
609
+ target_names.discard("")
610
+ for item in raw_datasets:
611
+ if not isinstance(item, Mapping):
612
+ continue
613
+ declaration = dict(item)
614
+ names = {
615
+ _normalize_dataset_name(str(declaration.get("dataset_id") or "")),
616
+ _normalize_dataset_name(str(declaration.get("table") or "")),
617
+ _normalize_dataset_name(str(declaration.get("logical_table") or "")),
618
+ }
619
+ names.discard("")
620
+ if target_names & names:
621
+ return declaration
622
+ for item in raw_datasets:
623
+ if isinstance(item, Mapping):
624
+ return dict(item)
625
+ dataset_id = payload.get("dataset_id")
626
+ if dataset_id:
627
+ return {
628
+ "dataset_id": dataset_id,
629
+ "table": payload.get("table") or output.get("table") or dataset_id,
630
+ "layer": layer or output.get("layer") or output.get("output_layer"),
631
+ "fields": output.get("fields"),
632
+ "primary_key": output.get("primary_key"),
633
+ "date_field": output.get("date_field"),
634
+ "partition_by": output.get("partition_by"),
635
+ "write_mode": output.get("write_mode"),
636
+ "storage": output.get("storage"),
637
+ "formats": output.get("supported_formats") or output.get("formats"),
638
+ }
639
+ return {}
640
+
641
+
642
+ def _summary_from_run(root: Path, run: Any) -> DatasetSummary | None:
643
+ output_paths = {str(key): str(value) for key, value in dict(getattr(run, "output_paths", {}) or {}).items()}
644
+ result = dict(getattr(run, "result", {}) or {})
645
+ download_result = dict(result.get("download_result") or {})
646
+ if not output_paths:
647
+ raw_output_paths = download_result.get("output_paths")
648
+ if isinstance(raw_output_paths, Mapping):
649
+ output_paths = {str(key): str(value) for key, value in raw_output_paths.items()}
650
+ if not output_paths:
651
+ return None
652
+
653
+ interface_name = str(
654
+ result.get("target_interface")
655
+ or download_result.get("interface_name")
656
+ or getattr(run, "downloader_profile", None)
657
+ or getattr(run, "collector_name", "")
658
+ )
659
+ quality = dict(getattr(run, "quality", {}) or download_result.get("quality") or {})
660
+ source_meta = dict(download_result.get("source_meta") or {})
661
+ write_metadata = _write_metadata_from_payload(download_result, quality)
662
+ layer = _layer_from_payload(download_result, result, output_paths)
663
+ declaration_payload = dict(download_result)
664
+ if "output" not in declaration_payload and isinstance(result.get("output"), Mapping):
665
+ declaration_payload["output"] = result["output"]
666
+ declared = _output_dataset_declaration(declaration_payload, interface_name=interface_name, layer=layer)
667
+ summary = DatasetSummary(
668
+ dataset=str(declared.get("dataset_id") or _dataset_id(interface_name, layer)),
669
+ interface_name=interface_name,
670
+ display_name_zh=_string_or_none(declared.get("display_name_zh")),
671
+ description=str(declared.get("description") or ""),
672
+ provider=getattr(run, "provider_id", None),
673
+ source=_source_from_payload(getattr(run, "provider_id", None), source_meta),
674
+ layer=_string_or_none(declared.get("layer")) or layer,
675
+ output_paths=output_paths,
676
+ row_count=_int_or_none(download_result.get("row_count") or quality.get("row_count_value")),
677
+ quality=quality,
678
+ quality_status=_string_or_none(quality.get("quality_status")),
679
+ quality_warnings=_string_list(quality.get("quality_warnings")),
680
+ quality_errors=_string_list(quality.get("quality_errors")),
681
+ latest_run_id=getattr(run, "run_id", None),
682
+ latest_run_status=getattr(run, "status", None),
683
+ updated_at=getattr(run, "finished_at", None) or getattr(run, "updated_at", None),
684
+ metadata={
685
+ "collector_name": getattr(run, "collector_name", None),
686
+ "task_id": getattr(run, "task_id", None),
687
+ "downloader_profile": getattr(run, "downloader_profile", None),
688
+ "params": dict(getattr(run, "params", {}) or {}),
689
+ "snapshot_date": download_result.get("snapshot_date"),
690
+ "log_path": download_result.get("log_path"),
691
+ "collector_output_dataset": dict(declared),
692
+ },
693
+ **write_metadata,
694
+ )
695
+ _apply_dataset_declaration(summary, declared, root=root)
696
+ return _enrich_summary_from_paths(summary, root=root)
697
+
698
+
699
+ def _iter_downloader_logs(root: Path, entries: Mapping[str, DatasetSummary]) -> Iterable[dict[str, Any]]:
700
+ seen: set[Path] = set()
701
+ for log_dir in _known_log_dirs(root):
702
+ yield from _iter_log_dir(log_dir, seen)
703
+ for summary in entries.values():
704
+ for path_text in summary.output_paths.values():
705
+ path = Path(path_text).expanduser()
706
+ candidates = []
707
+ if path.is_file():
708
+ candidates.append(path.parent.parent / "logs")
709
+ elif path.is_dir():
710
+ candidates.append(path / "logs")
711
+ candidates.append(path.parent / "logs")
712
+ for log_dir in candidates:
713
+ yield from _iter_log_dir(log_dir, seen)
714
+
715
+
716
+ def _known_log_dirs(root: Path) -> Iterable[Path]:
717
+ """Yield metadata log directories under known AxData data roots only."""
718
+
719
+ candidates = [
720
+ root / "raw",
721
+ root / "staging",
722
+ root / "core",
723
+ root / "factor",
724
+ root / "snapshot",
725
+ root / "snapshots",
726
+ ]
727
+ for candidate in candidates:
728
+ if not candidate.exists():
729
+ continue
730
+ yield from candidate.glob("*/logs")
731
+ yield from candidate.glob("*/*/logs")
732
+ yield from candidate.glob("*/*/*/logs")
733
+
734
+
735
+ def _iter_log_dir(log_dir: Path, seen: set[Path]) -> Iterable[dict[str, Any]]:
736
+ try:
737
+ resolved = log_dir.resolve()
738
+ except OSError:
739
+ return
740
+ if resolved in seen or not resolved.exists():
741
+ return
742
+ seen.add(resolved)
743
+ log_paths = sorted(
744
+ resolved.glob("*.json"),
745
+ key=lambda path: _path_sort_mtime(path),
746
+ reverse=True,
747
+ )[:MAX_DOWNLOADER_LOGS_PER_DIR]
748
+ for log_path in log_paths:
749
+ try:
750
+ payload = json.loads(log_path.read_text(encoding="utf-8"))
751
+ except (OSError, json.JSONDecodeError):
752
+ continue
753
+ if isinstance(payload, dict):
754
+ payload.setdefault("log_path", str(log_path))
755
+ yield payload
756
+
757
+
758
+ def _summary_from_log(root: Path, payload: Mapping[str, Any]) -> DatasetSummary | None:
759
+ output_paths = payload.get("output_paths")
760
+ if not isinstance(output_paths, Mapping):
761
+ return None
762
+ interface_name = str(payload.get("interface_name") or payload.get("target_interface") or "")
763
+ if not interface_name:
764
+ return None
765
+ quality = dict(payload.get("quality") or {})
766
+ layer = _layer_from_payload(payload, {}, output_paths)
767
+ source_meta = dict(payload.get("source_meta") or {})
768
+ write_metadata = _write_metadata_from_payload(payload, quality)
769
+ declared = _output_dataset_declaration(payload, interface_name=interface_name, layer=layer)
770
+ summary = DatasetSummary(
771
+ dataset=str(declared.get("dataset_id") or _dataset_id(interface_name, layer)),
772
+ interface_name=interface_name,
773
+ display_name_zh=_string_or_none(declared.get("display_name_zh")),
774
+ description=str(declared.get("description") or ""),
775
+ provider=_string_or_none(payload.get("provider_id")),
776
+ source=_source_from_payload(payload.get("provider_id"), source_meta),
777
+ layer=_string_or_none(declared.get("layer")) or layer,
778
+ output_paths={str(key): str(value) for key, value in output_paths.items()},
779
+ row_count=_int_or_none(payload.get("row_count") or quality.get("row_count_value")),
780
+ quality=quality,
781
+ quality_status=_string_or_none(quality.get("quality_status")),
782
+ quality_warnings=_string_list(quality.get("quality_warnings")),
783
+ quality_errors=_string_list(quality.get("quality_errors")),
784
+ latest_run_id=_string_or_none(payload.get("job_id")),
785
+ latest_run_status=_string_or_none(payload.get("status")),
786
+ updated_at=_string_or_none(payload.get("finished_at")),
787
+ metadata={
788
+ "snapshot_date": payload.get("snapshot_date"),
789
+ "log_path": payload.get("log_path"),
790
+ "params": dict(payload.get("params") or {}) if isinstance(payload.get("params"), Mapping) else {},
791
+ "collector_output_dataset": dict(declared),
792
+ },
793
+ **write_metadata,
794
+ )
795
+ _apply_dataset_declaration(summary, declared, root=root)
796
+ return _enrich_summary_from_paths(summary, root=root)
797
+
798
+
799
+ def _summary_from_core_table(root: Path, table: str) -> DatasetSummary | None:
800
+ output_paths: dict[str, str] = {}
801
+ file_path = core_table_path(table, root)
802
+ partition_path = core_table_partition_path(table, root)
803
+ if file_path.exists():
804
+ output_paths["parquet"] = str(file_path)
805
+ elif partition_path.exists() and _parquet_dir_may_have_files(partition_path):
806
+ output_paths["parquet"] = str(partition_path)
807
+ else:
808
+ return None
809
+
810
+ schema = get_schema(table)
811
+ summary = DatasetSummary(
812
+ dataset=_dataset_id(table, "core"),
813
+ interface_name=table,
814
+ display_name_zh=schema.display_name_zh,
815
+ description=schema.description,
816
+ layer="core",
817
+ output_paths=output_paths,
818
+ columns=list(schema.field_names),
819
+ field_schema=_field_schema_from_table_schema(schema),
820
+ logical_table=schema.name,
821
+ storage_layout="core_table",
822
+ default_query_fields=list(schema.field_names),
823
+ default_filter_fields=_default_filter_fields(schema),
824
+ available_formats=["parquet", "csv", "duckdb"],
825
+ metadata={
826
+ "schema": schema.name,
827
+ "primary_key": list(schema.primary_key),
828
+ "date_field": schema.date_field,
829
+ },
830
+ primary_key=list(schema.primary_key),
831
+ date_field=schema.date_field,
832
+ )
833
+ return _enrich_summary_from_paths(summary, root=root)
834
+
835
+
836
+ def _merge_summary(entries: dict[str, DatasetSummary], summary: DatasetSummary) -> None:
837
+ existing = entries.get(summary.dataset)
838
+ if existing is None:
839
+ entries[summary.dataset] = summary
840
+ return
841
+ if _sort_time(summary.updated_at) >= _sort_time(existing.updated_at):
842
+ merged = summary
843
+ for key, value in existing.output_paths.items():
844
+ merged.output_paths.setdefault(key, value)
845
+ else:
846
+ merged = existing
847
+ for key, value in summary.output_paths.items():
848
+ merged.output_paths.setdefault(key, value)
849
+ if not merged.columns:
850
+ merged.columns = existing.columns or summary.columns
851
+ if not merged.display_name_zh:
852
+ merged.display_name_zh = existing.display_name_zh or summary.display_name_zh
853
+ if not merged.description:
854
+ merged.description = existing.description or summary.description
855
+ if not merged.field_schema:
856
+ merged.field_schema = existing.field_schema or summary.field_schema
857
+ if not merged.logical_table:
858
+ merged.logical_table = existing.logical_table or summary.logical_table
859
+ if not merged.storage_layout:
860
+ merged.storage_layout = existing.storage_layout or summary.storage_layout
861
+ if not merged.default_query_fields:
862
+ merged.default_query_fields = existing.default_query_fields or summary.default_query_fields
863
+ if not merged.default_filter_fields:
864
+ merged.default_filter_fields = existing.default_filter_fields or summary.default_filter_fields
865
+ if not merged.available_formats:
866
+ merged.available_formats = existing.available_formats or summary.available_formats
867
+ if not merged.write_mode:
868
+ merged.write_mode = existing.write_mode or summary.write_mode
869
+ if not merged.partition_by:
870
+ merged.partition_by = existing.partition_by or summary.partition_by
871
+ if not merged.primary_key:
872
+ merged.primary_key = existing.primary_key or summary.primary_key
873
+ if not merged.date_field:
874
+ merged.date_field = existing.date_field or summary.date_field
875
+ entries[summary.dataset] = merged
876
+
877
+
878
+ def _apply_dataset_declaration(
879
+ summary: DatasetSummary,
880
+ declaration: Mapping[str, Any],
881
+ *,
882
+ root: Path,
883
+ ) -> None:
884
+ if not declaration:
885
+ return
886
+ summary.display_name_zh = summary.display_name_zh or _string_or_none(declaration.get("display_name_zh"))
887
+ summary.description = summary.description or str(declaration.get("description") or "")
888
+ summary.logical_table = summary.logical_table or _string_or_none(
889
+ declaration.get("table") or declaration.get("logical_table")
890
+ )
891
+ storage = declaration.get("storage")
892
+ storage_layout = storage.get("layout") if isinstance(storage, Mapping) else None
893
+ summary.storage_layout = summary.storage_layout or _string_or_none(
894
+ declaration.get("storage_layout") or storage_layout
895
+ )
896
+ summary.default_query_fields = summary.default_query_fields or _string_list(
897
+ declaration.get("default_query_fields")
898
+ )
899
+ summary.default_filter_fields = summary.default_filter_fields or _string_list(
900
+ declaration.get("default_filter_fields")
901
+ )
902
+ summary.available_formats = summary.available_formats or _declared_formats(declaration, {})
903
+ fields = _field_schema_from_declaration(declaration)
904
+ if fields:
905
+ summary.field_schema = fields
906
+ if not summary.columns:
907
+ summary.columns = [field["name"] for field in fields if field.get("name")]
908
+ if not summary.primary_key:
909
+ summary.primary_key = _string_list(declaration.get("primary_key"))
910
+ if not summary.partition_by:
911
+ summary.partition_by = _string_list(declaration.get("partition_by"))
912
+ if not summary.date_field:
913
+ summary.date_field = _string_or_none(declaration.get("date_field"))
914
+ expected_paths = _declared_output_paths(root, declaration, declaration)
915
+ if expected_paths:
916
+ summary.metadata = {
917
+ **summary.metadata,
918
+ "expected_output_paths": {
919
+ **dict(summary.metadata.get("expected_output_paths") or {}),
920
+ **expected_paths,
921
+ },
922
+ }
923
+ _apply_known_schema(summary)
924
+
925
+
926
+ def _apply_known_schema(summary: DatasetSummary) -> None:
927
+ schema = _schema_for_summary(summary)
928
+ if schema is None:
929
+ return
930
+ has_existing_parquet = bool(_existing_parquet_paths(summary.output_paths))
931
+ summary.display_name_zh = summary.display_name_zh or schema.display_name_zh
932
+ summary.description = summary.description or schema.description
933
+ summary.logical_table = summary.logical_table or schema.name
934
+ if not summary.field_schema or _field_schema_needs_descriptions(summary.field_schema):
935
+ summary.field_schema = _field_schema_from_table_schema(schema)
936
+ if not summary.columns and not has_existing_parquet:
937
+ summary.columns = list(schema.field_names)
938
+ summary.primary_key = summary.primary_key or list(schema.primary_key)
939
+ summary.date_field = summary.date_field or schema.date_field
940
+ summary.default_query_fields = summary.default_query_fields or list(schema.field_names)
941
+ summary.default_filter_fields = summary.default_filter_fields or _default_filter_fields(schema)
942
+ summary.available_formats = summary.available_formats or ["parquet", "csv", "duckdb"]
943
+
944
+
945
+ def _field_schema_needs_descriptions(fields: Sequence[Mapping[str, Any]]) -> bool:
946
+ return not any(
947
+ field.get("description_zh") or field.get("display_name_zh") != field.get("name")
948
+ for field in fields
949
+ )
950
+
951
+
952
+ def _schema_for_summary(summary: DatasetSummary) -> TableSchema | None:
953
+ for candidate in (summary.logical_table, summary.dataset, summary.interface_name):
954
+ if not candidate:
955
+ continue
956
+ try:
957
+ return get_schema(candidate)
958
+ except KeyError:
959
+ continue
960
+ return None
961
+
962
+
963
+ def _field_schema_from_table_schema(schema: TableSchema) -> list[dict[str, Any]]:
964
+ return [_field_schema_from_field(field) for field in schema.fields]
965
+
966
+
967
+ def _field_schema_from_field(field: Field) -> dict[str, Any]:
968
+ return {
969
+ "name": field.name,
970
+ "type": field.dtype,
971
+ "display_name_zh": field.description_zh or field.description or field.name,
972
+ "description": field.description,
973
+ "description_zh": field.description_zh,
974
+ "required": not field.nullable,
975
+ "unit": field.unit,
976
+ "aliases": list(field.aliases),
977
+ }
978
+
979
+
980
+ def _field_schema_from_declaration(declaration: Mapping[str, Any]) -> list[dict[str, Any]]:
981
+ fields = declaration.get("fields")
982
+ if isinstance(fields, Mapping):
983
+ return [
984
+ _field_schema_from_mapping(name, value)
985
+ for name, value in fields.items()
986
+ ]
987
+ if isinstance(fields, Sequence) and not isinstance(fields, (str, bytes, bytearray)):
988
+ parsed: list[dict[str, Any]] = []
989
+ for item in fields:
990
+ if isinstance(item, Mapping):
991
+ name = _string_or_none(item.get("name"))
992
+ if name:
993
+ parsed.append(_field_schema_from_mapping(name, item))
994
+ elif isinstance(item, str) and item.strip():
995
+ parsed.append({"name": item.strip(), "type": "", "display_name_zh": item.strip()})
996
+ return parsed
997
+ columns = _string_list(declaration.get("columns") or declaration.get("expected_columns"))
998
+ return [{"name": column, "type": "", "display_name_zh": column} for column in columns]
999
+
1000
+
1001
+ def _field_schema_from_mapping(name: str, value: Any) -> dict[str, Any]:
1002
+ if not isinstance(value, Mapping):
1003
+ return {
1004
+ "name": name,
1005
+ "type": "",
1006
+ "display_name_zh": str(value) if value is not None else name,
1007
+ }
1008
+ return {
1009
+ "name": name,
1010
+ "type": _string_or_none(value.get("type") or value.get("dtype")) or "",
1011
+ "display_name_zh": _string_or_none(
1012
+ value.get("display_name_zh") or value.get("name_zh") or value.get("description_zh")
1013
+ )
1014
+ or name,
1015
+ "description": _string_or_none(value.get("description")) or "",
1016
+ "description_zh": _string_or_none(value.get("description_zh")) or "",
1017
+ "required": bool(value.get("required", False)),
1018
+ "unit": _string_or_none(value.get("unit")),
1019
+ }
1020
+
1021
+
1022
+ def _default_filter_fields(schema: TableSchema) -> list[str]:
1023
+ fields: list[str] = []
1024
+ for candidate in ("ts_code", "instrument_id", "symbol", "exchange"):
1025
+ if schema.has_field(candidate):
1026
+ fields.append(candidate)
1027
+ if schema.date_field:
1028
+ fields.extend(["start_date", "end_date"])
1029
+ return fields
1030
+
1031
+
1032
+ def _declared_formats(
1033
+ declaration: Mapping[str, Any],
1034
+ output: Mapping[str, Any],
1035
+ ) -> list[str]:
1036
+ values = _string_list(
1037
+ declaration.get("formats")
1038
+ or declaration.get("supported_formats")
1039
+ or output.get("supported_formats")
1040
+ or output.get("formats")
1041
+ or ["parquet"]
1042
+ )
1043
+ ordered = []
1044
+ for value in values:
1045
+ clean = value.strip().lower()
1046
+ if clean and clean not in ordered:
1047
+ ordered.append(clean)
1048
+ return ordered or ["parquet"]
1049
+
1050
+
1051
+ def _enrich_summary_from_paths(summary: DatasetSummary, *, root: Path) -> DatasetSummary:
1052
+ missing: list[str] = []
1053
+ for path_text in summary.output_paths.values():
1054
+ path = Path(path_text).expanduser()
1055
+ if not path.is_absolute():
1056
+ path = (root / path).resolve()
1057
+ if not path.exists():
1058
+ if len(missing) < MAX_MISSING_PATHS:
1059
+ missing.append(str(path))
1060
+ summary.missing_paths = missing
1061
+
1062
+ if not summary.columns:
1063
+ summary.columns = _quality_columns(summary.quality)
1064
+ _apply_known_schema(summary)
1065
+
1066
+ quality_date_range = summary.quality.get("date_range") if isinstance(summary.quality, Mapping) else None
1067
+ if isinstance(quality_date_range, Mapping):
1068
+ date_min = _string_or_none(quality_date_range.get("min"))
1069
+ date_max = _string_or_none(quality_date_range.get("max"))
1070
+ else:
1071
+ date_min = date_max = None
1072
+ if _date_field(summary):
1073
+ summary.date_min = summary.date_min or date_min
1074
+ summary.date_max = summary.date_max or date_max
1075
+ else:
1076
+ summary.datetime_min = summary.datetime_min or date_min
1077
+ summary.datetime_max = summary.datetime_max or date_max
1078
+
1079
+ needs_row_count = summary.row_count is None
1080
+ needs_columns = not summary.columns or (
1081
+ bool(_existing_parquet_paths(summary.output_paths))
1082
+ and bool(summary.field_schema)
1083
+ and summary.columns == [field.get("name") for field in summary.field_schema if field.get("name")]
1084
+ )
1085
+ needs_date_stats = bool(_date_field(summary) and not (date_min and date_max))
1086
+ if not (needs_row_count or needs_columns or needs_date_stats):
1087
+ return summary
1088
+
1089
+ parquet_paths = _existing_parquet_paths(summary.output_paths)
1090
+ if not parquet_paths:
1091
+ return summary
1092
+
1093
+ try:
1094
+ stats = _parquet_stats(
1095
+ parquet_paths,
1096
+ date_field=_date_field(summary),
1097
+ include_date_range=needs_date_stats,
1098
+ )
1099
+ except Exception as exc:
1100
+ summary.metadata = {**summary.metadata, "inspect_error": str(exc)}
1101
+ return summary
1102
+
1103
+ summary.row_count = summary.row_count if summary.row_count is not None else stats.get("row_count")
1104
+ if needs_columns:
1105
+ summary.columns = list(stats.get("columns") or [])
1106
+ if stats.get("stats_limited"):
1107
+ summary.metadata = {
1108
+ **summary.metadata,
1109
+ "parquet_stats_limited": True,
1110
+ "parquet_stats_file_count": stats.get("stats_file_count"),
1111
+ "parquet_stats_file_limit": MAX_PARQUET_STATS_FILES,
1112
+ "parquet_stats_dir_limit": MAX_PARQUET_STATS_DIRS,
1113
+ }
1114
+
1115
+ stats_date_min = _string_or_none(stats.get("date_min"))
1116
+ stats_date_max = _string_or_none(stats.get("date_max"))
1117
+
1118
+ if _date_field(summary):
1119
+ summary.date_min = summary.date_min or date_min or stats_date_min
1120
+ summary.date_max = summary.date_max or date_max or stats_date_max
1121
+ else:
1122
+ summary.datetime_min = summary.datetime_min or date_min or stats_date_min
1123
+ summary.datetime_max = summary.datetime_max or date_max or stats_date_max
1124
+ return summary
1125
+
1126
+
1127
+ def _existing_parquet_paths(output_paths: Mapping[str, str]) -> list[Path]:
1128
+ path_text = output_paths.get("parquet")
1129
+ if not path_text:
1130
+ return []
1131
+ path = Path(path_text).expanduser()
1132
+ if path.is_file() and path.suffix.lower() == ".parquet":
1133
+ return [path.resolve()]
1134
+ if path.is_dir():
1135
+ if not _parquet_dir_may_have_files(path):
1136
+ return []
1137
+ return [path.resolve()]
1138
+ return []
1139
+
1140
+
1141
+ def _parquet_stats(
1142
+ paths: Sequence[Path],
1143
+ *,
1144
+ date_field: str | None,
1145
+ include_date_range: bool = True,
1146
+ ) -> dict[str, Any]:
1147
+ import pyarrow.parquet as pq
1148
+
1149
+ columns: list[str] = []
1150
+ row_count = 0
1151
+ date_values: list[str] = []
1152
+ parquet_files, stats_limited, partition_dates = _parquet_files_for_stats(paths, date_field=date_field)
1153
+ date_values.extend(partition_dates)
1154
+ for path in parquet_files:
1155
+ parquet_file = pq.ParquetFile(path)
1156
+ metadata = parquet_file.metadata
1157
+ row_count += int(metadata.num_rows if metadata is not None else 0)
1158
+ physical_columns = [str(column) for column in parquet_file.schema_arrow.names]
1159
+ partition_values = _hive_partition_values(path)
1160
+ for column in (*physical_columns, *partition_values):
1161
+ if column not in columns:
1162
+ columns.append(column)
1163
+ if include_date_range and date_field:
1164
+ partition_date = partition_values.get(date_field)
1165
+ if partition_date is not None:
1166
+ date_values.append(_normalize_date_text(partition_date) or str(partition_date))
1167
+ elif date_field in physical_columns and metadata is not None:
1168
+ date_values.extend(_parquet_column_stat_values(metadata, physical_columns.index(date_field)))
1169
+
1170
+ result: dict[str, Any] = {
1171
+ "columns": columns,
1172
+ "stats_file_count": len(parquet_files),
1173
+ "stats_limited": stats_limited,
1174
+ }
1175
+ if not stats_limited:
1176
+ result["row_count"] = int(row_count)
1177
+ normalized_dates = sorted(
1178
+ value
1179
+ for value in (_normalize_date_text(item) for item in date_values)
1180
+ if value
1181
+ )
1182
+ if normalized_dates:
1183
+ result["date_min"] = normalized_dates[0]
1184
+ result["date_max"] = normalized_dates[-1]
1185
+ return result
1186
+
1187
+
1188
+ def _parquet_files_for_stats(paths: Sequence[Path], *, date_field: str | None) -> tuple[list[Path], bool, list[str]]:
1189
+ files: list[Path] = []
1190
+ partition_dates: list[str] = []
1191
+ limited = False
1192
+ for path in paths:
1193
+ if path.is_file():
1194
+ limited = _append_stats_file(files, path) or limited
1195
+ elif path.is_dir():
1196
+ if date_field:
1197
+ partition_dates.extend(_date_partition_values(path, date_field=date_field))
1198
+ limited = _append_stats_files_from_dir(files, path) or limited
1199
+ return files, limited, partition_dates
1200
+
1201
+
1202
+ def _append_stats_files_from_dir(files: list[Path], directory: Path) -> bool:
1203
+ remaining = max(MAX_PARQUET_STATS_FILES - len(files), 0)
1204
+ if remaining <= 0:
1205
+ return True
1206
+ found, limited = _bounded_parquet_files(directory, file_limit=remaining)
1207
+ files.extend(found)
1208
+ return limited
1209
+
1210
+
1211
+ def _append_stats_file(files: list[Path], path: Path) -> bool:
1212
+ if len(files) >= MAX_PARQUET_STATS_FILES:
1213
+ return True
1214
+ files.append(path.resolve())
1215
+ return False
1216
+
1217
+
1218
+ def _parquet_dir_may_have_files(directory: Path) -> bool:
1219
+ files, limited = _bounded_parquet_files(directory, file_limit=1)
1220
+ return bool(files) or limited
1221
+
1222
+
1223
+ def _bounded_parquet_files(directory: Path, *, file_limit: int) -> tuple[list[Path], bool]:
1224
+ if file_limit <= 0:
1225
+ return [], True
1226
+ files: list[Path] = []
1227
+ dirs_seen = 0
1228
+ for root, dirnames, filenames in os.walk(directory):
1229
+ dirs_seen += 1
1230
+ if dirs_seen > MAX_PARQUET_STATS_DIRS:
1231
+ return files, True
1232
+ dirnames.sort()
1233
+ for filename in sorted(filenames):
1234
+ if not filename.lower().endswith(".parquet"):
1235
+ continue
1236
+ files.append((Path(root) / filename).resolve())
1237
+ if len(files) >= file_limit:
1238
+ return files, True
1239
+ return files, False
1240
+
1241
+
1242
+ def _date_partition_values(directory: Path, *, date_field: str) -> list[str]:
1243
+ values: list[str] = []
1244
+ prefix = f"{date_field}="
1245
+ try:
1246
+ candidates = directory.glob(f"{date_field}=*")
1247
+ except OSError:
1248
+ return values
1249
+ for path in candidates:
1250
+ if path.is_dir() and path.name.startswith(prefix):
1251
+ values.append(path.name.split("=", 1)[1])
1252
+ return values
1253
+
1254
+
1255
+ def _parquet_column_stat_values(metadata: Any, column_index: int) -> list[str]:
1256
+ values: list[str] = []
1257
+ for row_group_index in range(metadata.num_row_groups):
1258
+ column = metadata.row_group(row_group_index).column(column_index)
1259
+ stats = column.statistics
1260
+ if stats is None:
1261
+ continue
1262
+ for value in (stats.min, stats.max):
1263
+ text = _stat_value_text(value)
1264
+ if text:
1265
+ values.append(text)
1266
+ return values
1267
+
1268
+
1269
+ def _hive_partition_values(path: Path) -> dict[str, str]:
1270
+ values: dict[str, str] = {}
1271
+ for part in path.parts[:-1]:
1272
+ if "=" not in part:
1273
+ continue
1274
+ key, value = part.split("=", 1)
1275
+ if key and key != "table":
1276
+ values[key] = value
1277
+ return values
1278
+
1279
+
1280
+ def _stat_value_text(value: Any) -> str | None:
1281
+ if value is None:
1282
+ return None
1283
+ if isinstance(value, bytes):
1284
+ return value.decode("utf-8", errors="ignore")
1285
+ if hasattr(value, "isoformat"):
1286
+ try:
1287
+ return value.isoformat()
1288
+ except (TypeError, ValueError):
1289
+ pass
1290
+ return str(value)
1291
+
1292
+
1293
+ def _query_parquet(
1294
+ paths: Sequence[Path],
1295
+ *,
1296
+ fields: Sequence[str] | None,
1297
+ filters: Mapping[str, Any],
1298
+ date_field: str | None,
1299
+ start: str | None,
1300
+ end: str | None,
1301
+ limit: int,
1302
+ known_columns: Sequence[str] | None = None,
1303
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1304
+ import duckdb
1305
+
1306
+ read_path = _parquet_read_path(paths, date_field=date_field, start=start, end=end)
1307
+ if read_path is _NO_MATCHING_PARQUET_PARTITIONS:
1308
+ available = list(known_columns or [])
1309
+ selected = list(fields or available)
1310
+ missing = [field for field in selected if available and field not in available]
1311
+ if missing:
1312
+ raise DataBrowserError("Unknown field(s): " + ", ".join(missing))
1313
+ for field in filters:
1314
+ if available and field not in available:
1315
+ raise DataBrowserError(f"Unknown filter field: {field}")
1316
+ return [], selected
1317
+ where_sql, params = _where_clause(filters, date_field=date_field, start=start, end=end)
1318
+ with duckdb.connect(database=":memory:") as conn:
1319
+ available = [
1320
+ str(row[0])
1321
+ for row in conn.execute(
1322
+ "DESCRIBE SELECT * FROM read_parquet(?, hive_partitioning = true, union_by_name = true)",
1323
+ [read_path],
1324
+ ).fetchall()
1325
+ ]
1326
+ visible_available = _visible_preview_columns(available, known_columns)
1327
+ selected = list(fields or visible_available)
1328
+ missing = [field for field in selected if field not in available]
1329
+ if missing:
1330
+ raise DataBrowserError("Unknown field(s): " + ", ".join(missing))
1331
+ for field in filters:
1332
+ if field not in available:
1333
+ raise DataBrowserError(f"Unknown filter field: {field}")
1334
+ if date_field and (start or end) and date_field not in available:
1335
+ raise DataBrowserError(f"Date filter field {date_field!r} is not present in dataset.")
1336
+ select_sql = ", ".join(_quote_identifier(field) for field in selected) or "*"
1337
+ sql = (
1338
+ f"SELECT {select_sql} "
1339
+ "FROM read_parquet(?, hive_partitioning = true, union_by_name = true)"
1340
+ f"{where_sql} LIMIT ?"
1341
+ )
1342
+ frame = conn.execute(sql, [read_path, *params, limit]).fetchdf()
1343
+ return frame.to_dict(orient="records"), list(frame.columns)
1344
+
1345
+
1346
+ def _visible_preview_columns(available: Sequence[str], known_columns: Sequence[str] | None) -> list[str]:
1347
+ known = {str(column) for column in known_columns or []}
1348
+ visible: list[str] = []
1349
+ for column in available:
1350
+ if column == "table" and column not in known:
1351
+ continue
1352
+ visible.append(column)
1353
+ return visible
1354
+
1355
+
1356
+ def _parquet_read_path(
1357
+ paths: Sequence[Path],
1358
+ *,
1359
+ date_field: str | None = None,
1360
+ start: str | None = None,
1361
+ end: str | None = None,
1362
+ ) -> str | list[str] | object:
1363
+ if len(paths) == 1:
1364
+ only = paths[0]
1365
+ if only.is_file():
1366
+ return str(only)
1367
+ if only.is_dir():
1368
+ date_globs = _date_partition_globs(only, date_field=date_field, start=start, end=end)
1369
+ if date_globs is not None:
1370
+ return date_globs or _NO_MATCHING_PARQUET_PARTITIONS
1371
+ return str(only / "**" / "*.parquet")
1372
+ common = Path(os.path.commonpath([str(path.parent) for path in paths]))
1373
+ return str(common / "**" / "*.parquet")
1374
+
1375
+
1376
+ def _date_partition_globs(
1377
+ directory: Path,
1378
+ *,
1379
+ date_field: str | None,
1380
+ start: str | None,
1381
+ end: str | None,
1382
+ ) -> list[str] | None:
1383
+ if not date_field or not (start or end):
1384
+ return None
1385
+ partition_dirs = [
1386
+ path
1387
+ for path in directory.glob(f"{date_field}=*")
1388
+ if path.is_dir()
1389
+ ]
1390
+ date_files = [path for path in directory.glob("*.parquet") if _date_file_value(path) is not None]
1391
+ if not partition_dirs and not date_files:
1392
+ return None
1393
+
1394
+ start_compact = start.replace("-", "") if start else None
1395
+ end_compact = end.replace("-", "") if end else None
1396
+ if start_compact is not None and (len(start_compact) != 8 or not start_compact.isdigit()):
1397
+ return None
1398
+ if end_compact is not None and (len(end_compact) != 8 or not end_compact.isdigit()):
1399
+ return None
1400
+
1401
+ globs: list[str] = []
1402
+ for path in sorted(partition_dirs):
1403
+ value = path.name.split("=", 1)[1].replace("-", "")
1404
+ if start_compact is not None and value < start_compact:
1405
+ continue
1406
+ if end_compact is not None and value > end_compact:
1407
+ continue
1408
+ globs.append(str(path / "**" / "*.parquet"))
1409
+ for path in sorted(date_files):
1410
+ value = _date_file_value(path)
1411
+ if value is None:
1412
+ continue
1413
+ if start_compact is not None and value < start_compact:
1414
+ continue
1415
+ if end_compact is not None and value > end_compact:
1416
+ continue
1417
+ globs.append(str(path))
1418
+ return globs
1419
+
1420
+
1421
+ def _date_file_value(path: Path) -> str | None:
1422
+ value = path.stem.replace("-", "")
1423
+ return value if len(value) == 8 and value.isdigit() else None
1424
+
1425
+
1426
+ def _where_clause(
1427
+ filters: Mapping[str, Any],
1428
+ *,
1429
+ date_field: str | None,
1430
+ start: str | None,
1431
+ end: str | None,
1432
+ ) -> tuple[str, list[Any]]:
1433
+ clauses: list[str] = []
1434
+ params: list[Any] = []
1435
+ for field, value in filters.items():
1436
+ quoted = _quote_identifier(field)
1437
+ if isinstance(value, (list, tuple, set, frozenset)):
1438
+ values = [item for item in value if item is not None]
1439
+ if not values:
1440
+ clauses.append("1 = 0")
1441
+ continue
1442
+ placeholders = ", ".join("?" for _ in values)
1443
+ clauses.append(f"{quoted} IN ({placeholders})")
1444
+ params.extend(values)
1445
+ elif value is None:
1446
+ clauses.append(f"{quoted} IS NULL")
1447
+ else:
1448
+ clauses.append(f"{quoted} = ?")
1449
+ params.append(value)
1450
+
1451
+ if date_field and (start or end):
1452
+ quoted_date = _quote_identifier(date_field)
1453
+ if start:
1454
+ clauses.append(f"REPLACE(CAST({quoted_date} AS VARCHAR), '-', '') >= ?")
1455
+ params.append(start)
1456
+ if end:
1457
+ clauses.append(f"REPLACE(CAST({quoted_date} AS VARCHAR), '-', '') <= ?")
1458
+ params.append(end)
1459
+
1460
+ if not clauses:
1461
+ return "", params
1462
+ return " WHERE " + " AND ".join(clauses), params
1463
+
1464
+
1465
+ def _normalize_preview_filters(
1466
+ summary: DatasetSummary,
1467
+ filters: Mapping[str, Any] | None,
1468
+ symbol: str | None,
1469
+ ) -> dict[str, Any]:
1470
+ normalized = {
1471
+ str(key): _normalize_filter_value(value)
1472
+ for key, value in dict(filters or {}).items()
1473
+ if value is not None and str(key).strip()
1474
+ }
1475
+ if symbol:
1476
+ symbol_field = _symbol_field(summary)
1477
+ if symbol_field:
1478
+ normalized.setdefault(symbol_field, symbol)
1479
+ return normalized
1480
+
1481
+
1482
+ def _symbol_field(summary: DatasetSummary) -> str | None:
1483
+ columns = set(summary.columns)
1484
+ for candidate in ("ts_code", "instrument_id", "symbol", "code"):
1485
+ if candidate in columns:
1486
+ return candidate
1487
+ return None
1488
+
1489
+
1490
+ def _quality_columns(quality: Mapping[str, Any]) -> list[str]:
1491
+ columns = quality.get("schema_columns") if isinstance(quality, Mapping) else None
1492
+ if isinstance(columns, str):
1493
+ return [field.strip() for field in columns.split(",") if field.strip()]
1494
+ if isinstance(columns, Iterable):
1495
+ return [str(field) for field in columns if str(field).strip()]
1496
+ return []
1497
+
1498
+
1499
+ def _date_field(summary: DatasetSummary) -> str | None:
1500
+ if summary.date_field:
1501
+ return summary.date_field
1502
+ quality_field = summary.quality.get("date_field") if isinstance(summary.quality, Mapping) else None
1503
+ if isinstance(quality_field, str) and quality_field:
1504
+ return quality_field
1505
+ metadata_field = summary.metadata.get("date_field") if isinstance(summary.metadata, Mapping) else None
1506
+ if isinstance(metadata_field, str) and metadata_field:
1507
+ return metadata_field
1508
+ columns = set(summary.columns)
1509
+ for candidate in ("trade_date", "cal_date", "publish_date", "report_date", "date", "trade_time", "quote_time", "datetime"):
1510
+ if candidate in columns:
1511
+ return candidate
1512
+ try:
1513
+ schema = get_schema(summary.interface_name)
1514
+ except KeyError:
1515
+ return None
1516
+ return schema.date_field or schema.datetime_field
1517
+
1518
+
1519
+ def _normalize_fields(fields: Sequence[str] | str | None) -> list[str] | None:
1520
+ if fields is None or fields == "":
1521
+ return None
1522
+ if isinstance(fields, str):
1523
+ return [field.strip() for field in fields.split(",") if field.strip()]
1524
+ return [str(field).strip() for field in fields if str(field).strip()]
1525
+
1526
+
1527
+ def _normalize_filter_value(value: Any) -> Any:
1528
+ if isinstance(value, str):
1529
+ return _normalize_date_text(value)
1530
+ if isinstance(value, tuple):
1531
+ return [_normalize_filter_value(item) for item in value]
1532
+ if isinstance(value, list):
1533
+ return [_normalize_filter_value(item) for item in value]
1534
+ return value
1535
+
1536
+
1537
+ def _normalize_date_text(value: Any) -> str | None:
1538
+ if value is None or value == "":
1539
+ return None
1540
+ text = str(value).strip()
1541
+ if len(text) == 10 and text[4] == "-" and text[7] == "-":
1542
+ return text.replace("-", "")
1543
+ return text
1544
+
1545
+
1546
+ def _date_filter_payload(start: str | None, end: str | None) -> dict[str, Any]:
1547
+ payload: dict[str, Any] = {}
1548
+ if start:
1549
+ payload["start"] = start
1550
+ if end:
1551
+ payload["end"] = end
1552
+ return payload
1553
+
1554
+
1555
+ def _layer_from_payload(
1556
+ primary: Mapping[str, Any],
1557
+ secondary: Mapping[str, Any],
1558
+ output_paths: Mapping[str, Any],
1559
+ ) -> str | None:
1560
+ for payload in (primary, secondary):
1561
+ output = payload.get("output")
1562
+ if isinstance(output, Mapping):
1563
+ layer = _string_or_none(output.get("layer") or output.get("output_layer"))
1564
+ if layer:
1565
+ return layer
1566
+ for key in ("layer", "output_layer"):
1567
+ layer = _string_or_none(payload.get(key))
1568
+ if layer:
1569
+ return layer
1570
+ joined = " ".join(str(path).replace("\\", "/") for path in output_paths.values())
1571
+ for layer in KNOWN_DATA_LAYERS:
1572
+ if f"/{layer}/" in joined or f"\\{layer}\\" in joined or f"{layer}/table=" in joined:
1573
+ return layer
1574
+ return "snapshot"
1575
+
1576
+
1577
+ def _write_metadata_from_payload(primary: Mapping[str, Any], quality: Mapping[str, Any]) -> dict[str, Any]:
1578
+ nested = primary.get("write_metadata")
1579
+ source = dict(nested) if isinstance(nested, Mapping) else primary
1580
+ primary_key = _string_list(
1581
+ source.get("primary_key")
1582
+ if "primary_key" in source
1583
+ else quality.get("write_primary_key")
1584
+ )
1585
+ partition_by = _string_list(source.get("partition_by") or quality.get("partition_by"))
1586
+ date_field = _string_or_none(
1587
+ source.get("date_field")
1588
+ or quality.get("write_date_field")
1589
+ or quality.get("date_field")
1590
+ )
1591
+ return {
1592
+ "write_mode": _string_or_none(source.get("write_mode") or quality.get("write_mode")),
1593
+ "partition_by": partition_by,
1594
+ "primary_key": primary_key,
1595
+ "date_field": date_field,
1596
+ "replace_range_start": _string_or_none(
1597
+ source.get("replace_range_start") or quality.get("replace_range_start")
1598
+ ),
1599
+ "replace_range_end": _string_or_none(
1600
+ source.get("replace_range_end") or quality.get("replace_range_end")
1601
+ ),
1602
+ "rows_before": _int_or_none(source.get("rows_before") if "rows_before" in source else quality.get("rows_before")),
1603
+ "rows_written": _int_or_none(
1604
+ source.get("rows_written") if "rows_written" in source else quality.get("rows_written")
1605
+ ),
1606
+ "rows_after": _int_or_none(source.get("rows_after") if "rows_after" in source else quality.get("rows_after")),
1607
+ "duplicate_rows_dropped": _int_or_none(
1608
+ source.get("duplicate_rows_dropped")
1609
+ if "duplicate_rows_dropped" in source
1610
+ else quality.get("duplicate_rows_dropped")
1611
+ ),
1612
+ "partitions_touched": _string_list(source.get("partitions_touched") or quality.get("partitions_touched")),
1613
+ }
1614
+
1615
+
1616
+ def _dataset_id(interface_name: str, layer: str | None) -> str:
1617
+ clean = interface_name.strip() or "dataset"
1618
+ if layer and layer not in {"snapshot", "core"}:
1619
+ return f"{layer}.{clean}"
1620
+ return clean
1621
+
1622
+
1623
+ def _source_from_payload(provider: Any, source_meta: Mapping[str, Any]) -> str | None:
1624
+ source = _string_or_none(source_meta.get("source") or source_meta.get("source_code"))
1625
+ if source:
1626
+ return source
1627
+ provider_text = _string_or_none(provider)
1628
+ if provider_text and "." in provider_text:
1629
+ return provider_text.rsplit(".", 1)[-1]
1630
+ return provider_text
1631
+
1632
+
1633
+ def _normalize_dataset_name(value: str) -> str:
1634
+ return re.sub(r"\s+", "", value.strip().lower())
1635
+
1636
+
1637
+ def _sort_time(value: str | None) -> str:
1638
+ return value or ""
1639
+
1640
+
1641
+ def _path_sort_mtime(path: Path) -> float:
1642
+ try:
1643
+ return path.stat().st_mtime
1644
+ except OSError:
1645
+ return 0.0
1646
+
1647
+
1648
+ def _quote_identifier(identifier: str) -> str:
1649
+ return '"' + identifier.replace('"', '""') + '"'
1650
+
1651
+
1652
+ def _int_or_none(value: Any) -> int | None:
1653
+ if value is None or value == "":
1654
+ return None
1655
+ try:
1656
+ return int(value)
1657
+ except (TypeError, ValueError):
1658
+ return None
1659
+
1660
+
1661
+ def _string_or_none(value: Any) -> str | None:
1662
+ if value is None:
1663
+ return None
1664
+ text = str(value).strip()
1665
+ return text or None
1666
+
1667
+
1668
+ def _string_list(value: Any) -> list[str]:
1669
+ if value is None:
1670
+ return []
1671
+ if isinstance(value, str):
1672
+ return [value]
1673
+ if isinstance(value, Iterable):
1674
+ return [str(item) for item in value]
1675
+ return [str(value)]
1676
+
1677
+
1678
+ def _jsonable(value: Any) -> Any:
1679
+ if value is None or isinstance(value, (str, int, bool)):
1680
+ return value
1681
+ if isinstance(value, float):
1682
+ return value if value == value and value not in {float("inf"), float("-inf")} else None
1683
+ if isinstance(value, (datetime, date)):
1684
+ return value.isoformat()
1685
+ if isinstance(value, Mapping):
1686
+ return {str(key): _jsonable(item) for key, item in value.items()}
1687
+ if isinstance(value, tuple):
1688
+ return [_jsonable(item) for item in value]
1689
+ if isinstance(value, list):
1690
+ return [_jsonable(item) for item in value]
1691
+ if hasattr(value, "item"):
1692
+ try:
1693
+ return _jsonable(value.item())
1694
+ except (TypeError, ValueError):
1695
+ pass
1696
+ return value