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.
- axdata/__init__.py +16 -0
- axdata/cache.py +19 -0
- axdata/client.py +1223 -0
- axdata-0.1.0.dist-info/METADATA +238 -0
- axdata-0.1.0.dist-info/RECORD +427 -0
- axdata-0.1.0.dist-info/WHEEL +5 -0
- axdata-0.1.0.dist-info/entry_points.txt +8 -0
- axdata-0.1.0.dist-info/licenses/LICENSE +162 -0
- axdata-0.1.0.dist-info/top_level.txt +6 -0
- axdata_core/__init__.py +221 -0
- axdata_core/_tdx_wire/__init__.py +35 -0
- axdata_core/_tdx_wire/_shim.py +140 -0
- axdata_core/_tdx_wire/api/__init__.py +51 -0
- axdata_core/_tdx_wire/api/auction.py +24 -0
- axdata_core/_tdx_wire/api/bars.py +24 -0
- axdata_core/_tdx_wire/api/base.py +26 -0
- axdata_core/_tdx_wire/api/codes.py +25 -0
- axdata_core/_tdx_wire/api/corporate.py +24 -0
- axdata_core/_tdx_wire/api/finance.py +24 -0
- axdata_core/_tdx_wire/api/intraday.py +24 -0
- axdata_core/_tdx_wire/api/quotes.py +26 -0
- axdata_core/_tdx_wire/api/resources.py +25 -0
- axdata_core/_tdx_wire/api/session.py +24 -0
- axdata_core/_tdx_wire/api/trades.py +24 -0
- axdata_core/_tdx_wire/client.py +33 -0
- axdata_core/_tdx_wire/exceptions.py +27 -0
- axdata_core/_tdx_wire/hosts.py +106 -0
- axdata_core/_tdx_wire/models/__init__.py +93 -0
- axdata_core/_tdx_wire/models/auction.py +26 -0
- axdata_core/_tdx_wire/models/corporate.py +27 -0
- axdata_core/_tdx_wire/models/finance.py +26 -0
- axdata_core/_tdx_wire/models/intraday.py +31 -0
- axdata_core/_tdx_wire/models/kline.py +27 -0
- axdata_core/_tdx_wire/models/quote.py +32 -0
- axdata_core/_tdx_wire/models/resource.py +24 -0
- axdata_core/_tdx_wire/models/security.py +24 -0
- axdata_core/_tdx_wire/models/session.py +27 -0
- axdata_core/_tdx_wire/models/subchart.py +25 -0
- axdata_core/_tdx_wire/models/trade.py +28 -0
- axdata_core/_tdx_wire/protocol/__init__.py +49 -0
- axdata_core/_tdx_wire/protocol/commands/__init__.py +43 -0
- axdata_core/_tdx_wire/protocol/commands/auction.py +110 -0
- axdata_core/_tdx_wire/protocol/commands/codec.py +43 -0
- axdata_core/_tdx_wire/protocol/commands/corporate.py +98 -0
- axdata_core/_tdx_wire/protocol/commands/finance.py +96 -0
- axdata_core/_tdx_wire/protocol/commands/intraday.py +122 -0
- axdata_core/_tdx_wire/protocol/commands/klines.py +102 -0
- axdata_core/_tdx_wire/protocol/commands/lookup.py +36 -0
- axdata_core/_tdx_wire/protocol/commands/price_limits.py +89 -0
- axdata_core/_tdx_wire/protocol/commands/quotes.py +102 -0
- axdata_core/_tdx_wire/protocol/commands/registry.py +36 -0
- axdata_core/_tdx_wire/protocol/commands/resources.py +99 -0
- axdata_core/_tdx_wire/protocol/commands/security.py +103 -0
- axdata_core/_tdx_wire/protocol/commands/session.py +82 -0
- axdata_core/_tdx_wire/protocol/commands/subchart.py +97 -0
- axdata_core/_tdx_wire/protocol/commands/trades.py +107 -0
- axdata_core/_tdx_wire/protocol/constants.py +124 -0
- axdata_core/_tdx_wire/protocol/frame.py +68 -0
- axdata_core/_tdx_wire/protocol/unit.py +85 -0
- axdata_core/_tdx_wire/py.typed +1 -0
- axdata_core/_tdx_wire/transport/__init__.py +34 -0
- axdata_core/_tdx_wire/transport/base.py +25 -0
- axdata_core/_tdx_wire/transport/memory.py +24 -0
- axdata_core/_tdx_wire/transport/pool.py +32 -0
- axdata_core/_tdx_wire/transport/socket.py +39 -0
- axdata_core/adapters/__init__.py +0 -0
- axdata_core/adapters/cls/__init__.py +17 -0
- axdata_core/adapters/cls/provider_bridge.py +23 -0
- axdata_core/adapters/cls/request.py +632 -0
- axdata_core/adapters/cls/source_adapter_registry.py +31 -0
- axdata_core/adapters/cninfo/__init__.py +17 -0
- axdata_core/adapters/cninfo/provider_bridge.py +25 -0
- axdata_core/adapters/cninfo/request.py +2425 -0
- axdata_core/adapters/cninfo/source_adapter_registry.py +42 -0
- axdata_core/adapters/eastmoney/__init__.py +17 -0
- axdata_core/adapters/eastmoney/provider_bridge.py +25 -0
- axdata_core/adapters/eastmoney/request.py +1128 -0
- axdata_core/adapters/eastmoney/source_adapter_registry.py +42 -0
- axdata_core/adapters/exchange/__init__.py +17 -0
- axdata_core/adapters/exchange/provider_bridge.py +25 -0
- axdata_core/adapters/exchange/request.py +1214 -0
- axdata_core/adapters/exchange/source_adapter_registry.py +42 -0
- axdata_core/adapters/kph/__init__.py +17 -0
- axdata_core/adapters/kph/provider_bridge.py +23 -0
- axdata_core/adapters/kph/request.py +509 -0
- axdata_core/adapters/kph/source_adapter_registry.py +31 -0
- axdata_core/adapters/sina/__init__.py +17 -0
- axdata_core/adapters/sina/provider_bridge.py +25 -0
- axdata_core/adapters/sina/request.py +5155 -0
- axdata_core/adapters/sina/source_adapter_registry.py +42 -0
- axdata_core/adapters/tdx/__init__.py +32 -0
- axdata_core/adapters/tdx/adjustment.py +50 -0
- axdata_core/adapters/tdx/adjustment_fetch.py +51 -0
- axdata_core/adapters/tdx/auction_fetch.py +51 -0
- axdata_core/adapters/tdx/client_factory.py +110 -0
- axdata_core/adapters/tdx/code_fetch.py +62 -0
- axdata_core/adapters/tdx/codes.py +54 -0
- axdata_core/adapters/tdx/derived_rows.py +69 -0
- axdata_core/adapters/tdx/downloader.py +193 -0
- axdata_core/adapters/tdx/downloader_interface_sets.py +50 -0
- axdata_core/adapters/tdx/downloader_profiles.py +43 -0
- axdata_core/adapters/tdx/downloader_registry.py +115 -0
- axdata_core/adapters/tdx/execution_utils.py +55 -0
- axdata_core/adapters/tdx/f10_executor.py +57 -0
- axdata_core/adapters/tdx/f10_normalize.py +61 -0
- axdata_core/adapters/tdx/f10_params.py +57 -0
- axdata_core/adapters/tdx/f10_postprocess.py +67 -0
- axdata_core/adapters/tdx/f10_render.py +48 -0
- axdata_core/adapters/tdx/f10_request.py +62 -0
- axdata_core/adapters/tdx/finance_fetch.py +61 -0
- axdata_core/adapters/tdx/finance_maps.py +54 -0
- axdata_core/adapters/tdx/finance_normalize.py +57 -0
- axdata_core/adapters/tdx/host_config.py +115 -0
- axdata_core/adapters/tdx/interface_sets.py +50 -0
- axdata_core/adapters/tdx/intraday_fetch.py +68 -0
- axdata_core/adapters/tdx/kline_helpers.py +58 -0
- axdata_core/adapters/tdx/limit_ladder_fetch.py +60 -0
- axdata_core/adapters/tdx/limit_ladder_topics.py +53 -0
- axdata_core/adapters/tdx/normalize_utils.py +75 -0
- axdata_core/adapters/tdx/options.py +61 -0
- axdata_core/adapters/tdx/price_limit_calendar.py +49 -0
- axdata_core/adapters/tdx/price_limit_fetch.py +53 -0
- axdata_core/adapters/tdx/price_limit_history.py +50 -0
- axdata_core/adapters/tdx/price_limits.py +58 -0
- axdata_core/adapters/tdx/provider_bridge.py +68 -0
- axdata_core/adapters/tdx/quote_fetch.py +61 -0
- axdata_core/adapters/tdx/quote_identity.py +49 -0
- axdata_core/adapters/tdx/rank_fetch.py +55 -0
- axdata_core/adapters/tdx/rank_params.py +52 -0
- axdata_core/adapters/tdx/realtime_refresh.py +45 -0
- axdata_core/adapters/tdx/request.py +11 -0
- axdata_core/adapters/tdx/request_adapter_runtime.py +41 -0
- axdata_core/adapters/tdx/request_client.py +51 -0
- axdata_core/adapters/tdx/request_compat.py +89 -0
- axdata_core/adapters/tdx/request_dispatch.py +50 -0
- axdata_core/adapters/tdx/request_filters.py +60 -0
- axdata_core/adapters/tdx/request_host_config.py +110 -0
- axdata_core/adapters/tdx/request_limits.py +59 -0
- axdata_core/adapters/tdx/request_methods.py +41 -0
- axdata_core/adapters/tdx/request_params.py +62 -0
- axdata_core/adapters/tdx/request_seams.py +41 -0
- axdata_core/adapters/tdx/resources/__init__.py +0 -0
- axdata_core/adapters/tdx/resources/finance_maps/__init__.py +0 -0
- axdata_core/adapters/tdx/resources/finance_maps/incon.dat +3702 -0
- axdata_core/adapters/tdx/resources/finance_maps/tdxhy.cfg +5620 -0
- axdata_core/adapters/tdx/resources/finance_maps/tdxzs.cfg +605 -0
- axdata_core/adapters/tdx/security_codes.py +54 -0
- axdata_core/adapters/tdx/series_history.py +57 -0
- axdata_core/adapters/tdx/server_cache.py +58 -0
- axdata_core/adapters/tdx/snapshot_normalize.py +56 -0
- axdata_core/adapters/tdx/source_adapter_registry.py +69 -0
- axdata_core/adapters/tdx/source_execution.py +44 -0
- axdata_core/adapters/tdx/source_execution_registry.py +70 -0
- axdata_core/adapters/tdx/stats_cache.py +184 -0
- axdata_core/adapters/tdx/stats_models.py +159 -0
- axdata_core/adapters/tdx/stats_resource.py +304 -0
- axdata_core/adapters/tdx/status_fetch.py +55 -0
- axdata_core/adapters/tdx/time_series_normalize.py +67 -0
- axdata_core/adapters/tdx/tqlex.py +50 -0
- axdata_core/adapters/tdx/wire_requests.py +60 -0
- axdata_core/adapters/tdx_ext/__init__.py +66 -0
- axdata_core/adapters/tdx_ext/client.py +688 -0
- axdata_core/adapters/tdx_ext/exceptions.py +51 -0
- axdata_core/adapters/tdx_ext/host_config.py +40 -0
- axdata_core/adapters/tdx_ext/interface_sets.py +65 -0
- axdata_core/adapters/tdx_ext/local_cache.py +649 -0
- axdata_core/adapters/tdx_ext/models.py +96 -0
- axdata_core/adapters/tdx_ext/options.py +36 -0
- axdata_core/adapters/tdx_ext/pool.py +153 -0
- axdata_core/adapters/tdx_ext/provider_bridge.py +35 -0
- axdata_core/adapters/tdx_ext/request.py +498 -0
- axdata_core/adapters/tdx_ext/request_execution.py +118 -0
- axdata_core/adapters/tdx_ext/request_instruments.py +221 -0
- axdata_core/adapters/tdx_ext/request_normalize.py +356 -0
- axdata_core/adapters/tdx_ext/request_params.py +157 -0
- axdata_core/adapters/tdx_ext/request_series.py +98 -0
- axdata_core/adapters/tdx_ext/server_cache.py +13 -0
- axdata_core/adapters/tdx_ext/servers.py +35 -0
- axdata_core/adapters/tdx_ext/source_adapter_registry.py +42 -0
- axdata_core/adapters/tdx_ext/source_execution.py +19 -0
- axdata_core/adapters/tdx_ext/source_execution_registry.py +43 -0
- axdata_core/adapters/tencent/__init__.py +17 -0
- axdata_core/adapters/tencent/provider_bridge.py +25 -0
- axdata_core/adapters/tencent/request.py +729 -0
- axdata_core/adapters/tencent/source_adapter_registry.py +42 -0
- axdata_core/axp.py +1843 -0
- axdata_core/builtin_providers.py +586 -0
- axdata_core/builtin_source_declarations.py +156 -0
- axdata_core/cli.py +1818 -0
- axdata_core/collector_registry.py +227 -0
- axdata_core/collector_runner.py +1016 -0
- axdata_core/collector_scheduler.py +3155 -0
- axdata_core/collector_templates.py +339 -0
- axdata_core/data_browser.py +1696 -0
- axdata_core/diagnostics.py +734 -0
- axdata_core/downloader_engine.py +1480 -0
- axdata_core/downloader_registry.py +62 -0
- axdata_core/downloaders.py +1619 -0
- axdata_core/legacy_provider_adapter.py +68 -0
- axdata_core/paths.py +51 -0
- axdata_core/plugin_config.py +272 -0
- axdata_core/plugin_status.py +419 -0
- axdata_core/plugins.py +1363 -0
- axdata_core/provider_catalog.py +462 -0
- axdata_core/provider_registry.py +833 -0
- axdata_core/quality.py +86 -0
- axdata_core/query.py +242 -0
- axdata_core/resources/__init__.py +2 -0
- axdata_core/resources/tdx_extended_servers.json +22 -0
- axdata_core/resources/tdx_quote_servers.json +49 -0
- axdata_core/sample_collectors.py +42 -0
- axdata_core/schema.py +385 -0
- axdata_core/source_adapter_factory.py +95 -0
- axdata_core/source_adapter_options.py +18 -0
- axdata_core/source_errors.py +43 -0
- axdata_core/source_execution_options.py +37 -0
- axdata_core/source_execution_registry.py +27 -0
- axdata_core/source_projection.py +21 -0
- axdata_core/source_request.py +441 -0
- axdata_core/source_session.py +355 -0
- axdata_core/sources/__init__.py +40 -0
- axdata_core/sources/base.py +112 -0
- axdata_core/sources/catalog.py +60 -0
- axdata_core/sources/cls/__init__.py +5 -0
- axdata_core/sources/cls/catalog.py +231 -0
- axdata_core/sources/cninfo/__init__.py +5 -0
- axdata_core/sources/cninfo/catalog.py +1994 -0
- axdata_core/sources/display_docs.py +139 -0
- axdata_core/sources/eastmoney/__init__.py +5 -0
- axdata_core/sources/eastmoney/catalog.py +509 -0
- axdata_core/sources/exchange/__init__.py +5 -0
- axdata_core/sources/exchange/catalog.py +361 -0
- axdata_core/sources/kph/__init__.py +5 -0
- axdata_core/sources/kph/catalog.py +255 -0
- axdata_core/sources/sina/__init__.py +5 -0
- axdata_core/sources/sina/catalog.py +2951 -0
- axdata_core/sources/tdx/__init__.py +5 -0
- axdata_core/sources/tdx/catalog.py +4546 -0
- axdata_core/sources/tdx_ext/__init__.py +5 -0
- axdata_core/sources/tdx_ext/catalog.py +1518 -0
- axdata_core/sources/tencent/__init__.py +5 -0
- axdata_core/sources/tencent/catalog.py +370 -0
- axdata_core/storage.py +186 -0
- axdata_core/tdx_f10_catalog.py +52 -0
- axdata_core/tdx_f10_models.py +50 -0
- axdata_core/tdx_f10_names.py +47 -0
- axdata_core/tdx_f10_specs.py +56 -0
- axdata_core/tdx_plugin_required.py +43 -0
- axdata_core/tdx_server_config.py +65 -0
- axdata_core/trade_calendar_cache.py +397 -0
- axdata_source_cninfo/__init__.py +15 -0
- axdata_source_cninfo/adapter.py +31 -0
- axdata_source_cninfo/axdata-provider.json +384 -0
- axdata_source_cninfo/catalog.py +209 -0
- axdata_source_cninfo/metadata.py +8 -0
- axdata_source_cninfo/provider.py +41 -0
- axdata_source_tdx/__init__.py +15 -0
- axdata_source_tdx/_tdx_wire/__init__.py +26 -0
- axdata_source_tdx/_tdx_wire/_binary.py +218 -0
- axdata_source_tdx/_tdx_wire/_code_utils.py +42 -0
- axdata_source_tdx/_tdx_wire/_command_codec.py +186 -0
- axdata_source_tdx/_tdx_wire/_command_codes.py +74 -0
- axdata_source_tdx/_tdx_wire/_command_defaults.py +15 -0
- axdata_source_tdx/_tdx_wire/_command_dispatch.py +96 -0
- axdata_source_tdx/_tdx_wire/_command_layouts.py +96 -0
- axdata_source_tdx/_tdx_wire/_command_lookup.py +25 -0
- axdata_source_tdx/_tdx_wire/_command_metadata.py +32 -0
- axdata_source_tdx/_tdx_wire/_command_registry.py +61 -0
- axdata_source_tdx/_tdx_wire/_connection_defaults.py +13 -0
- axdata_source_tdx/_tdx_wire/_host_defaults.py +51 -0
- axdata_source_tdx/_tdx_wire/_host_probe.py +81 -0
- axdata_source_tdx/_tdx_wire/_host_resource.py +70 -0
- axdata_source_tdx/_tdx_wire/_host_utils.py +31 -0
- axdata_source_tdx/_tdx_wire/_market.py +66 -0
- axdata_source_tdx/_tdx_wire/_request_defaults.py +11 -0
- axdata_source_tdx/_tdx_wire/_security_classification.py +61 -0
- axdata_source_tdx/_tdx_wire/_time_utils.py +7 -0
- axdata_source_tdx/_tdx_wire/api/__init__.py +45 -0
- axdata_source_tdx/_tdx_wire/api/auction.py +25 -0
- axdata_source_tdx/_tdx_wire/api/bars.py +31 -0
- axdata_source_tdx/_tdx_wire/api/base.py +39 -0
- axdata_source_tdx/_tdx_wire/api/codes.py +25 -0
- axdata_source_tdx/_tdx_wire/api/corporate.py +11 -0
- axdata_source_tdx/_tdx_wire/api/finance.py +10 -0
- axdata_source_tdx/_tdx_wire/api/intraday.py +38 -0
- axdata_source_tdx/_tdx_wire/api/quotes.py +78 -0
- axdata_source_tdx/_tdx_wire/api/resources.py +34 -0
- axdata_source_tdx/_tdx_wire/api/session.py +16 -0
- axdata_source_tdx/_tdx_wire/api/trades.py +34 -0
- axdata_source_tdx/_tdx_wire/client.py +470 -0
- axdata_source_tdx/_tdx_wire/exceptions.py +25 -0
- axdata_source_tdx/_tdx_wire/hosts.py +27 -0
- axdata_source_tdx/_tdx_wire/models/__init__.py +87 -0
- axdata_source_tdx/_tdx_wire/models/auction.py +58 -0
- axdata_source_tdx/_tdx_wire/models/corporate.py +59 -0
- axdata_source_tdx/_tdx_wire/models/finance.py +80 -0
- axdata_source_tdx/_tdx_wire/models/intraday.py +112 -0
- axdata_source_tdx/_tdx_wire/models/kline.py +58 -0
- axdata_source_tdx/_tdx_wire/models/quote.py +262 -0
- axdata_source_tdx/_tdx_wire/models/resource.py +18 -0
- axdata_source_tdx/_tdx_wire/models/security.py +28 -0
- axdata_source_tdx/_tdx_wire/models/session.py +30 -0
- axdata_source_tdx/_tdx_wire/models/subchart.py +39 -0
- axdata_source_tdx/_tdx_wire/models/trade.py +46 -0
- axdata_source_tdx/_tdx_wire/protocol/__init__.py +43 -0
- axdata_source_tdx/_tdx_wire/protocol/_frame_constants.py +5 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/__init__.py +37 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/auction.py +165 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/codec.py +37 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/corporate.py +163 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/finance.py +180 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/intraday.py +382 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/klines.py +396 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/lookup.py +31 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/price_limits.py +110 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/quotes.py +804 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/registry.py +30 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/resources.py +107 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/security.py +163 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/session.py +129 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/subchart.py +182 -0
- axdata_source_tdx/_tdx_wire/protocol/commands/trades.py +275 -0
- axdata_source_tdx/_tdx_wire/protocol/constants.py +70 -0
- axdata_source_tdx/_tdx_wire/protocol/frame.py +141 -0
- axdata_source_tdx/_tdx_wire/protocol/unit.py +98 -0
- axdata_source_tdx/_tdx_wire/py.typed +1 -0
- axdata_source_tdx/_tdx_wire/transport/__init__.py +28 -0
- axdata_source_tdx/_tdx_wire/transport/base.py +21 -0
- axdata_source_tdx/_tdx_wire/transport/memory.py +42 -0
- axdata_source_tdx/_tdx_wire/transport/pool.py +148 -0
- axdata_source_tdx/_tdx_wire/transport/socket.py +373 -0
- axdata_source_tdx/adapter.py +31 -0
- axdata_source_tdx/adjustment.py +163 -0
- axdata_source_tdx/adjustment_fetch.py +148 -0
- axdata_source_tdx/auction_fetch.py +162 -0
- axdata_source_tdx/axdata-provider.json +23680 -0
- axdata_source_tdx/catalog.py +39 -0
- axdata_source_tdx/client_factory.py +61 -0
- axdata_source_tdx/code_fetch.py +579 -0
- axdata_source_tdx/codes.py +69 -0
- axdata_source_tdx/collectors.py +525 -0
- axdata_source_tdx/derived_rows.py +500 -0
- axdata_source_tdx/downloader.py +139 -0
- axdata_source_tdx/downloader_interface_sets.py +29 -0
- axdata_source_tdx/downloader_profiles.py +731 -0
- axdata_source_tdx/downloader_registry.py +85 -0
- axdata_source_tdx/execution_utils.py +141 -0
- axdata_source_tdx/f10_executor.py +414 -0
- axdata_source_tdx/f10_normalize.py +169 -0
- axdata_source_tdx/f10_params.py +221 -0
- axdata_source_tdx/f10_postprocess.py +405 -0
- axdata_source_tdx/f10_render.py +29 -0
- axdata_source_tdx/f10_request.py +365 -0
- axdata_source_tdx/finance_fetch.py +514 -0
- axdata_source_tdx/finance_maps.py +377 -0
- axdata_source_tdx/finance_normalize.py +273 -0
- axdata_source_tdx/host_config.py +51 -0
- axdata_source_tdx/interface_sets.py +180 -0
- axdata_source_tdx/intraday_fetch.py +600 -0
- axdata_source_tdx/kline_helpers.py +333 -0
- axdata_source_tdx/limit_ladder_fetch.py +614 -0
- axdata_source_tdx/limit_ladder_topics.py +280 -0
- axdata_source_tdx/metadata.py +8 -0
- axdata_source_tdx/normalize_utils.py +264 -0
- axdata_source_tdx/options.py +195 -0
- axdata_source_tdx/price_limit_calendar.py +106 -0
- axdata_source_tdx/price_limit_fetch.py +264 -0
- axdata_source_tdx/price_limit_history.py +100 -0
- axdata_source_tdx/price_limits.py +142 -0
- axdata_source_tdx/provider.py +44 -0
- axdata_source_tdx/provider_bridge.py +43 -0
- axdata_source_tdx/quote_fetch.py +485 -0
- axdata_source_tdx/quote_identity.py +50 -0
- axdata_source_tdx/rank_fetch.py +316 -0
- axdata_source_tdx/rank_params.py +181 -0
- axdata_source_tdx/realtime_refresh.py +51 -0
- axdata_source_tdx/request_adapter.py +533 -0
- axdata_source_tdx/request_adapter_runtime.py +302 -0
- axdata_source_tdx/request_client.py +98 -0
- axdata_source_tdx/request_compat.py +432 -0
- axdata_source_tdx/request_dispatch.py +138 -0
- axdata_source_tdx/request_entrypoints.py +146 -0
- axdata_source_tdx/request_filters.py +154 -0
- axdata_source_tdx/request_host_config.py +103 -0
- axdata_source_tdx/request_limits.py +15 -0
- axdata_source_tdx/request_methods.py +1910 -0
- axdata_source_tdx/request_params.py +225 -0
- axdata_source_tdx/request_seams.py +534 -0
- axdata_source_tdx/resources/__init__.py +0 -0
- axdata_source_tdx/resources/finance_maps/__init__.py +1 -0
- axdata_source_tdx/resources/finance_maps/incon.dat +3702 -0
- axdata_source_tdx/resources/finance_maps/tdxhy.cfg +5620 -0
- axdata_source_tdx/resources/finance_maps/tdxzs.cfg +605 -0
- axdata_source_tdx/resources/tdx_extended_servers.json +22 -0
- axdata_source_tdx/resources/tdx_quote_servers.json +49 -0
- axdata_source_tdx/security_codes.py +127 -0
- axdata_source_tdx/series_history.py +348 -0
- axdata_source_tdx/server_cache.py +21 -0
- axdata_source_tdx/snapshot_normalize.py +232 -0
- axdata_source_tdx/source_adapter_registry.py +42 -0
- axdata_source_tdx/source_execution.py +22 -0
- axdata_source_tdx/source_execution_registry.py +43 -0
- axdata_source_tdx/stats_cache.py +150 -0
- axdata_source_tdx/stats_models.py +161 -0
- axdata_source_tdx/stats_resource.py +242 -0
- axdata_source_tdx/status_fetch.py +209 -0
- axdata_source_tdx/tdx_f10_catalog.py +957 -0
- axdata_source_tdx/tdx_f10_models.py +47 -0
- axdata_source_tdx/tdx_f10_names.py +48 -0
- axdata_source_tdx/tdx_f10_specs.py +1142 -0
- axdata_source_tdx/tdx_server_config.py +389 -0
- axdata_source_tdx/time_series_normalize.py +370 -0
- axdata_source_tdx/tqlex.py +138 -0
- axdata_source_tdx/wire.py +32 -0
- axdata_source_tdx/wire_requests.py +190 -0
- axdata_source_tdx_ext/__init__.py +15 -0
- axdata_source_tdx_ext/adapter.py +31 -0
- axdata_source_tdx_ext/axdata-provider.json +9424 -0
- axdata_source_tdx_ext/catalog.py +33 -0
- axdata_source_tdx_ext/metadata.py +8 -0
- axdata_source_tdx_ext/provider.py +44 -0
- axdata_source_tencent/__init__.py +15 -0
- axdata_source_tencent/adapter.py +31 -0
- axdata_source_tencent/axdata-provider.json +291 -0
- axdata_source_tencent/catalog.py +190 -0
- axdata_source_tencent/metadata.py +8 -0
- 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
|