precis-cli 0.1.3__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.
- app/__init__.py +16 -0
- app/api/__init__.py +32 -0
- app/api/dependencies.py +185 -0
- app/api/main.py +301 -0
- app/api/middleware/__init__.py +34 -0
- app/api/middleware/exception_handler.py +126 -0
- app/api/middleware/request_logging.py +97 -0
- app/api/middleware/token_auth.py +215 -0
- app/api/models/__init__.py +151 -0
- app/api/models/connection_rules.py +98 -0
- app/api/models/files.py +99 -0
- app/api/models/full_validation.py +554 -0
- app/api/models/project.py +117 -0
- app/api/models/projects.py +80 -0
- app/api/models/schema.py +121 -0
- app/api/models/v2_responses.py +46 -0
- app/api/models/validation.py +303 -0
- app/api/models/workspace.py +118 -0
- app/api/routers/__init__.py +56 -0
- app/api/routers/ai/__init__.py +36 -0
- app/api/routers/ai/chat.py +243 -0
- app/api/routers/ai/generate.py +156 -0
- app/api/routers/ai/hardware.py +199 -0
- app/api/routers/ai/jobs.py +544 -0
- app/api/routers/ai/migrate.py +471 -0
- app/api/routers/ai/models.py +352 -0
- app/api/routers/ai/ollama.py +108 -0
- app/api/routers/ai/providers.py +491 -0
- app/api/routers/ai/router.py +44 -0
- app/api/routers/ai/stream.py +281 -0
- app/api/routers/ai/utils.py +182 -0
- app/api/routers/core/__init__.py +34 -0
- app/api/routers/core/connection_rules.py +188 -0
- app/api/routers/core/data_sources.py +374 -0
- app/api/routers/core/regex.py +333 -0
- app/api/routers/core/reporting.py +263 -0
- app/api/routers/files/__init__.py +24 -0
- app/api/routers/files/ops.py +179 -0
- app/api/routers/files/transfer.py +101 -0
- app/api/routers/preview/__init__.py +35 -0
- app/api/routers/preview/content_mode.py +264 -0
- app/api/routers/preview/header_row.py +154 -0
- app/api/routers/preview/models.py +81 -0
- app/api/routers/preview/path_mode.py +440 -0
- app/api/routers/preview/router.py +30 -0
- app/api/routers/project/__init__.py +61 -0
- app/api/routers/project/base.py +111 -0
- app/api/routers/project/constraint.py +345 -0
- app/api/routers/project/full_config.py +411 -0
- app/api/routers/project/full_config_writer.py +327 -0
- app/api/routers/project/helpers.py +167 -0
- app/api/routers/project/inspection_fix.py +330 -0
- app/api/routers/project/manifest.py +605 -0
- app/api/routers/project/models.py +177 -0
- app/api/routers/project/pattern.py +268 -0
- app/api/routers/project/regex.py +358 -0
- app/api/routers/project/scanner.py +157 -0
- app/api/routers/project/schema.py +567 -0
- app/api/routers/project/schema_helpers.py +149 -0
- app/api/routers/project/settings.py +399 -0
- app/api/routers/project/template.py +325 -0
- app/api/routers/project/validation.py +227 -0
- app/api/routers/project/view.py +177 -0
- app/api/routers/project/workspaces.py +167 -0
- app/api/routers/projects/__init__.py +25 -0
- app/api/routers/projects/create.py +121 -0
- app/api/routers/projects/open.py +103 -0
- app/api/routers/projects/scan.py +100 -0
- app/api/routers/validation/__init__.py +39 -0
- app/api/routers/validation/common.py +263 -0
- app/api/routers/validation/content_mode.py +401 -0
- app/api/routers/validation/history.py +235 -0
- app/api/routers/validation/inline_mode.py +196 -0
- app/api/routers/validation/path_mode.py +314 -0
- app/api/routers/validation/router.py +51 -0
- app/api/services/full_validation_response_builder.py +412 -0
- app/api/services/io_error_messages.py +41 -0
- app/api/services/preview_service.py +248 -0
- app/cli/__init__.py +18 -0
- app/cli/__main__.py +30 -0
- app/cli/shared_services/__init__.py +42 -0
- app/cli/shared_services/config_ops.py +581 -0
- app/cli/shared_services/generation_ops.py +160 -0
- app/cli/shared_services/project_ops.py +288 -0
- app/cli/shell/__init__.py +48 -0
- app/cli/shell/commands/__init__.py +63 -0
- app/cli/shell/commands/ai/__init__.py +260 -0
- app/cli/shell/commands/ai/chat.py +331 -0
- app/cli/shell/commands/ai/delete.py +165 -0
- app/cli/shell/commands/ai/diff.py +70 -0
- app/cli/shell/commands/ai/display.py +428 -0
- app/cli/shell/commands/ai/executor.py +225 -0
- app/cli/shell/commands/ai/executor_utils.py +271 -0
- app/cli/shell/commands/ai/generate.py +285 -0
- app/cli/shell/commands/ai/interaction.py +262 -0
- app/cli/shell/commands/ai/migrate.py +281 -0
- app/cli/shell/commands/ai/resolver.py +224 -0
- app/cli/shell/commands/ai/status.py +101 -0
- app/cli/shell/commands/ai/switch.py +158 -0
- app/cli/shell/commands/ai/utils.py +52 -0
- app/cli/shell/commands/base.py +343 -0
- app/cli/shell/commands/config/__init__.py +131 -0
- app/cli/shell/commands/config/base.py +80 -0
- app/cli/shell/commands/config/check.py +246 -0
- app/cli/shell/commands/config/edit.py +148 -0
- app/cli/shell/commands/config/get.py +111 -0
- app/cli/shell/commands/config/init.py +123 -0
- app/cli/shell/commands/config/inspect.py +164 -0
- app/cli/shell/commands/config/list.py +104 -0
- app/cli/shell/commands/config/set.py +119 -0
- app/cli/shell/commands/config/show.py +150 -0
- app/cli/shell/commands/exit.py +67 -0
- app/cli/shell/commands/help.py +104 -0
- app/cli/shell/commands/infer_schema.py +156 -0
- app/cli/shell/commands/open.py +201 -0
- app/cli/shell/commands/project.py +215 -0
- app/cli/shell/commands/provider.py +619 -0
- app/cli/shell/commands/system.py +170 -0
- app/cli/shell/commands/validate.py +455 -0
- app/cli/shell/completer.py +231 -0
- app/cli/shell/config_storage.py +191 -0
- app/cli/shell/exceptions.py +90 -0
- app/cli/shell/formatter.py +371 -0
- app/cli/shell/interactive_menu.py +348 -0
- app/cli/shell/main.py +286 -0
- app/cli/shell/parser.py +266 -0
- app/cli/start.py +180 -0
- app/cli_main.py +52 -0
- app/mcp_server.py +311 -0
- app/shared/core/__init__.py +18 -0
- app/shared/core/app_version.py +59 -0
- app/shared/core/config/__init__.py +119 -0
- app/shared/core/config/server.py +193 -0
- app/shared/core/data_source/__init__.py +106 -0
- app/shared/core/data_source/loader.py +348 -0
- app/shared/core/data_source/loaders/__init__.py +213 -0
- app/shared/core/data_source/loaders/base.py +215 -0
- app/shared/core/data_source/loaders/converter.py +337 -0
- app/shared/core/data_source/loaders/csv_loader.py +252 -0
- app/shared/core/data_source/loaders/excel_loader.py +666 -0
- app/shared/core/data_source/loaders/extractor.py +268 -0
- app/shared/core/data_source/loaders/json_loader.py +354 -0
- app/shared/core/data_source/loaders/registry.py +88 -0
- app/shared/core/data_source/loaders/sql_loader.py +313 -0
- app/shared/core/data_source/loaders/strategies/__init__.py +100 -0
- app/shared/core/data_source/loaders/strategies/array_parser.py +277 -0
- app/shared/core/data_source/loaders/strategies/lines_parser.py +355 -0
- app/shared/core/data_source/loaders/strategies/object_parser.py +278 -0
- app/shared/core/data_source/schema_info.py +50 -0
- app/shared/core/data_source/specs/__init__.py +128 -0
- app/shared/core/data_source/specs/base.py +258 -0
- app/shared/core/data_source/specs/csv_source.py +137 -0
- app/shared/core/data_source/specs/excel_source.py +141 -0
- app/shared/core/data_source/specs/file_base.py +252 -0
- app/shared/core/data_source/specs/json_source.py +226 -0
- app/shared/core/data_source/specs/sql_source.py +161 -0
- app/shared/core/encoding.py +36 -0
- app/shared/core/io/__init__.py +24 -0
- app/shared/core/io/yaml.py +379 -0
- app/shared/core/manifest_schema/__init__.py +52 -0
- app/shared/core/manifest_schema/types.py +99 -0
- app/shared/core/manifest_schema/version.py +175 -0
- app/shared/core/patterns/__init__.py +24 -0
- app/shared/core/patterns/loader.py +134 -0
- app/shared/core/patterns/writer.py +244 -0
- app/shared/core/project/__init__.py +47 -0
- app/shared/core/project/constraint/builders/__init__.py +50 -0
- app/shared/core/project/constraint/builders/base.py +100 -0
- app/shared/core/project/constraint/builders/composite.py +77 -0
- app/shared/core/project/constraint/builders/conditional.py +67 -0
- app/shared/core/project/constraint/builders/foreign_key.py +53 -0
- app/shared/core/project/constraint/builders/registry.py +64 -0
- app/shared/core/project/constraint/builders/scripted.py +51 -0
- app/shared/core/project/constraint/builders/single_column.py +86 -0
- app/shared/core/project/constraint/builders/unique.py +53 -0
- app/shared/core/project/constraint/factory.py +170 -0
- app/shared/core/project/constraint/reader.py +214 -0
- app/shared/core/project/constraint/registry.py +233 -0
- app/shared/core/project/constraint/types/__init__.py +63 -0
- app/shared/core/project/constraint/types/constraint_file.py +261 -0
- app/shared/core/project/constraint/types/refs.py +460 -0
- app/shared/core/project/constraint/types.py +28 -0
- app/shared/core/project/constraint/writer.py +181 -0
- app/shared/core/project/loader/__init__.py +56 -0
- app/shared/core/project/loader/loader.py +30 -0
- app/shared/core/project/loader/loader_parts/config_inspector.py +137 -0
- app/shared/core/project/loader/loader_parts/embedded_constraints.py +224 -0
- app/shared/core/project/loader/loader_parts/file_loaders.py +58 -0
- app/shared/core/project/loader/loader_parts/inspection_ids.py +85 -0
- app/shared/core/project/loader/loader_parts/inspector_helpers.py +312 -0
- app/shared/core/project/loader/loader_parts/inspector_id_checks.py +274 -0
- app/shared/core/project/loader/loader_parts/inspector_reference_checks.py +690 -0
- app/shared/core/project/loader/loader_parts/inspector_uniqueness_checks.py +286 -0
- app/shared/core/project/loader/loader_parts/loading_error_messages.py +298 -0
- app/shared/core/project/loader/loader_parts/main.py +432 -0
- app/shared/core/project/loader/loader_parts/path_validation.py +117 -0
- app/shared/core/project/loader/loader_parts/runtime.py +125 -0
- app/shared/core/project/loader/types.py +246 -0
- app/shared/core/project/manifest/coverage.py +391 -0
- app/shared/core/project/manifest/reader.py +260 -0
- app/shared/core/project/manifest/types.py +91 -0
- app/shared/core/project/manifest/types_parts/__init__.py +60 -0
- app/shared/core/project/manifest/types_parts/constants.py +40 -0
- app/shared/core/project/manifest/types_parts/data_source.py +68 -0
- app/shared/core/project/manifest/types_parts/info.py +59 -0
- app/shared/core/project/manifest/types_parts/manifest.py +294 -0
- app/shared/core/project/manifest/types_parts/refs.py +153 -0
- app/shared/core/project/manifest/types_parts/settings.py +92 -0
- app/shared/core/project/manifest/types_parts/settings_file_processing.py +64 -0
- app/shared/core/project/manifest/types_parts/settings_script_security.py +78 -0
- app/shared/core/project/manifest/types_parts/settings_validation.py +83 -0
- app/shared/core/project/manifest/types_parts/template.py +66 -0
- app/shared/core/project/manifest/writer.py +262 -0
- app/shared/core/project/manual_data/__init__.py +27 -0
- app/shared/core/project/manual_data/types.py +75 -0
- app/shared/core/project/regex/reader.py +197 -0
- app/shared/core/project/regex/types.py +405 -0
- app/shared/core/project/regex/writer.py +123 -0
- app/shared/core/project/schema/reader.py +170 -0
- app/shared/core/project/schema/types.py +47 -0
- app/shared/core/project/schema/types_parts/__init__.py +36 -0
- app/shared/core/project/schema/types_parts/column.py +174 -0
- app/shared/core/project/schema/types_parts/column_utils.py +72 -0
- app/shared/core/project/schema/types_parts/constraint.py +165 -0
- app/shared/core/project/schema/types_parts/schema_id.py +66 -0
- app/shared/core/project/schema/types_parts/source.py +255 -0
- app/shared/core/project/schema/types_parts/source_options.py +347 -0
- app/shared/core/project/schema/types_parts/table.py +230 -0
- app/shared/core/project/schema/writer.py +139 -0
- app/shared/core/project/schema_ref_check.py +95 -0
- app/shared/core/project/template/__init__.py +27 -0
- app/shared/core/project/template/expander.py +263 -0
- app/shared/core/project/template/reader.py +120 -0
- app/shared/core/project/template/types.py +114 -0
- app/shared/core/project/transform/reader.py +76 -0
- app/shared/core/project/transform/types.py +116 -0
- app/shared/core/project/transform/writer.py +84 -0
- app/shared/core/pydantic_messages.py +59 -0
- app/shared/core/reporter/__init__.py +46 -0
- app/shared/core/reporter/reporter.py +220 -0
- app/shared/core/reporter/reporters/__init__.py +65 -0
- app/shared/core/reporter/reporters/base.py +188 -0
- app/shared/core/reporter/reporters/dingtalk_app_reporter.py +274 -0
- app/shared/core/reporter/reporters/email_reporter.py +271 -0
- app/shared/core/reporter/reporters/feishu_app_reporter.py +467 -0
- app/shared/core/reporter/reporters/local_file_reporter.py +208 -0
- app/shared/core/reporter/reporters/wecom_app_reporter.py +268 -0
- app/shared/core/utils/__init__.py +18 -0
- app/shared/core/utils/path_utils.py +60 -0
- app/shared/core/utils/regex_extract.py +113 -0
- app/shared/domain/__init__.py +114 -0
- app/shared/domain/constraints/__init__.py +76 -0
- app/shared/domain/constraints/allowed_values.py +211 -0
- app/shared/domain/constraints/base.py +141 -0
- app/shared/domain/constraints/charset.py +337 -0
- app/shared/domain/constraints/composite.py +174 -0
- app/shared/domain/constraints/condition_registry.py +130 -0
- app/shared/domain/constraints/conditional.py +629 -0
- app/shared/domain/constraints/date_logic.py +731 -0
- app/shared/domain/constraints/foreign_key.py +261 -0
- app/shared/domain/constraints/key_normalization.py +56 -0
- app/shared/domain/constraints/not_null.py +185 -0
- app/shared/domain/constraints/range.py +360 -0
- app/shared/domain/constraints/regex.py +218 -0
- app/shared/domain/constraints/scripted.py +276 -0
- app/shared/domain/constraints/unique.py +233 -0
- app/shared/domain/data_engine.py +384 -0
- app/shared/domain/data_types.py +113 -0
- app/shared/domain/data_types_parts/__init__.py +67 -0
- app/shared/domain/data_types_parts/base.py +204 -0
- app/shared/domain/data_types_parts/composite.py +250 -0
- app/shared/domain/data_types_parts/expression.py +202 -0
- app/shared/domain/data_types_parts/extracted.py +106 -0
- app/shared/domain/data_types_parts/json_types.py +234 -0
- app/shared/domain/data_types_parts/scalars.py +719 -0
- app/shared/domain/data_types_parts/sequence.py +129 -0
- app/shared/domain/dataset_schema.py +48 -0
- app/shared/domain/eval_sandbox.py +63 -0
- app/shared/domain/expression_system.py +366 -0
- app/shared/domain/regex_flags.py +53 -0
- app/shared/domain/schema/builder.py +223 -0
- app/shared/domain/schema/models.py +339 -0
- app/shared/domain/transforms/__init__.py +28 -0
- app/shared/domain/transforms/aggregate.py +141 -0
- app/shared/domain/transforms/base.py +171 -0
- app/shared/domain/transforms/cast_type.py +120 -0
- app/shared/domain/transforms/concat.py +103 -0
- app/shared/domain/transforms/conditional_assign.py +114 -0
- app/shared/domain/transforms/date_format.py +81 -0
- app/shared/domain/transforms/digits.py +77 -0
- app/shared/domain/transforms/drop_duplicates.py +94 -0
- app/shared/domain/transforms/fill_na.py +94 -0
- app/shared/domain/transforms/filter_rows.py +97 -0
- app/shared/domain/transforms/lookup.py +78 -0
- app/shared/domain/transforms/lower_case.py +70 -0
- app/shared/domain/transforms/map_value.py +90 -0
- app/shared/domain/transforms/math_expr.py +112 -0
- app/shared/domain/transforms/modulo.py +82 -0
- app/shared/domain/transforms/regex_extract.py +103 -0
- app/shared/domain/transforms/registry.py +95 -0
- app/shared/domain/transforms/replace.py +88 -0
- app/shared/domain/transforms/sort_rows.py +94 -0
- app/shared/domain/transforms/string_split.py +84 -0
- app/shared/domain/transforms/strip.py +73 -0
- app/shared/domain/transforms/substring.py +92 -0
- app/shared/domain/transforms/upper_case.py +70 -0
- app/shared/domain/transforms/weighted_sum.py +104 -0
- app/shared/domain/validation_constraints.py +69 -0
- app/shared/services/__init__.py +62 -0
- app/shared/services/ai/__init__.py +48 -0
- app/shared/services/ai/agent/__init__.py +40 -0
- app/shared/services/ai/agent/chat_tools/__init__.py +47 -0
- app/shared/services/ai/agent/chat_tools/apply_actions.py +592 -0
- app/shared/services/ai/agent/chat_tools/ask_user.py +237 -0
- app/shared/services/ai/agent/chat_tools/read_canvas.py +140 -0
- app/shared/services/ai/agent/chat_tools/read_project.py +160 -0
- app/shared/services/ai/agent/chat_tools/read_table.py +301 -0
- app/shared/services/ai/agent/chat_tools/schemas.py +105 -0
- app/shared/services/ai/agent/chat_tools/validate_table.py +131 -0
- app/shared/services/ai/agent/executor.py +554 -0
- app/shared/services/ai/agent/memory.py +256 -0
- app/shared/services/ai/agent/planner.py +282 -0
- app/shared/services/ai/agent/tool_registry.py +296 -0
- app/shared/services/ai/agent/tools/__init__.py +32 -0
- app/shared/services/ai/agent/tools/config_generate.py +133 -0
- app/shared/services/ai/agent/tools/config_refine.py +109 -0
- app/shared/services/ai/agent/tools/config_validate.py +403 -0
- app/shared/services/ai/agent/tools/merge_results.py +136 -0
- app/shared/services/ai/agent/tools/plan_chunks.py +79 -0
- app/shared/services/ai/agent/tools/script_parse.py +359 -0
- app/shared/services/ai/agent/types.py +138 -0
- app/shared/services/ai/chat_agent_runner.py +596 -0
- app/shared/services/ai/chat_orchestrator.py +725 -0
- app/shared/services/ai/failure_messages.py +65 -0
- app/shared/services/ai/job_storage.py +274 -0
- app/shared/services/ai/migrate_service.py +550 -0
- app/shared/services/ai/streaming/__init__.py +65 -0
- app/shared/services/ai/streaming/event_journal.py +197 -0
- app/shared/services/ai/streaming/orchestrator.py +262 -0
- app/shared/services/ai/streaming/pending_interaction_store.py +227 -0
- app/shared/services/ai/streaming/sse_response.py +148 -0
- app/shared/services/ai/streaming/types.py +73 -0
- app/shared/services/ai/types.py +213 -0
- app/shared/services/ai/utils.py +322 -0
- app/shared/services/diff/config_diff.py +320 -0
- app/shared/services/hardware.py +237 -0
- app/shared/services/llm/__init__.py +62 -0
- app/shared/services/llm/actions/__init__.py +42 -0
- app/shared/services/llm/actions/_canvas_validator.py +147 -0
- app/shared/services/llm/actions/_constraint_validator.py +401 -0
- app/shared/services/llm/actions/_regex_validator.py +85 -0
- app/shared/services/llm/actions/_schema_validator.py +82 -0
- app/shared/services/llm/actions/_settings_validator.py +76 -0
- app/shared/services/llm/actions/_transform_validator.py +78 -0
- app/shared/services/llm/actions/action_handlers.py +400 -0
- app/shared/services/llm/actions/action_parser.py +63 -0
- app/shared/services/llm/actions/action_processor.py +468 -0
- app/shared/services/llm/actions/action_validator.py +311 -0
- app/shared/services/llm/actions/diff_compute.py +195 -0
- app/shared/services/llm/actions/regex_handlers.py +284 -0
- app/shared/services/llm/actions/registry.py +336 -0
- app/shared/services/llm/actions/schema_handlers.py +335 -0
- app/shared/services/llm/actions/settings_handlers.py +180 -0
- app/shared/services/llm/actions/specs.py +259 -0
- app/shared/services/llm/actions/transform_handlers.py +279 -0
- app/shared/services/llm/actions/validation_types.py +147 -0
- app/shared/services/llm/cache/__init__.py +18 -0
- app/shared/services/llm/cache/response_cache.py +80 -0
- app/shared/services/llm/chat/__init__.py +35 -0
- app/shared/services/llm/chat/chat_system_prompt.py +561 -0
- app/shared/services/llm/chat/response_parser.py +296 -0
- app/shared/services/llm/config/__init__.py +45 -0
- app/shared/services/llm/config/crypto.py +135 -0
- app/shared/services/llm/config/loader.py +258 -0
- app/shared/services/llm/config/models.py +202 -0
- app/shared/services/llm/config/presets.py +128 -0
- app/shared/services/llm/config_generator.py +163 -0
- app/shared/services/llm/constraints/__init__.py +41 -0
- app/shared/services/llm/constraints/constraint_builder.py +177 -0
- app/shared/services/llm/constraints/constraint_deletion.py +84 -0
- app/shared/services/llm/constraints/constraint_id.py +174 -0
- app/shared/services/llm/constraints/frontend_instructions.py +333 -0
- app/shared/services/llm/constraints/inline_batch.py +279 -0
- app/shared/services/llm/discovery/__init__.py +35 -0
- app/shared/services/llm/discovery/scanner.py +181 -0
- app/shared/services/llm/generation/__init__.py +42 -0
- app/shared/services/llm/generation/agent_wiring.py +156 -0
- app/shared/services/llm/generation/config_builder.py +386 -0
- app/shared/services/llm/generation/errors.py +36 -0
- app/shared/services/llm/generation/existing_config.py +99 -0
- app/shared/services/llm/generation/profiler.py +279 -0
- app/shared/services/llm/generation/prompt_builder.py +199 -0
- app/shared/services/llm/generation/response_parser.py +144 -0
- app/shared/services/llm/generation/service.py +622 -0
- app/shared/services/llm/models.py +104 -0
- app/shared/services/llm/providers/__init__.py +48 -0
- app/shared/services/llm/providers/base.py +310 -0
- app/shared/services/llm/providers/cached_provider.py +90 -0
- app/shared/services/llm/providers/ollama.py +498 -0
- app/shared/services/llm/providers/openai.py +334 -0
- app/shared/services/llm/providers/registry.py +108 -0
- app/shared/services/llm/schema_resolver.py +141 -0
- app/shared/services/llm/suggestion_utils.py +219 -0
- app/shared/services/llm/validate_executor.py +163 -0
- app/shared/services/llm/yaml_io.py +406 -0
- app/shared/services/preview/__init__.py +18 -0
- app/shared/services/preview/loader.py +145 -0
- app/shared/services/preview/path_validation.py +138 -0
- app/shared/services/project_loader.py +57 -0
- app/shared/services/schema_inference.py +260 -0
- app/shared/services/schema_runtime_builder.py +120 -0
- app/shared/services/validation/__init__.py +52 -0
- app/shared/services/validation/chunked_loader.py +509 -0
- app/shared/services/validation/dag/__init__.py +35 -0
- app/shared/services/validation/dag/builder.py +141 -0
- app/shared/services/validation/dag/executor.py +225 -0
- app/shared/services/validation/dag/sorter.py +77 -0
- app/shared/services/validation/data_loader.py +231 -0
- app/shared/services/validation/engine.py +446 -0
- app/shared/services/validation/executor.py +1030 -0
- app/shared/services/validation/extractors.py +266 -0
- app/shared/services/validation/history.py +209 -0
- app/shared/services/validation/json_payload.py +136 -0
- app/shared/services/validation/loader.py +152 -0
- app/shared/services/validation/memory_monitor.py +179 -0
- app/shared/services/validation/postprocess.py +208 -0
- app/shared/services/validation/progress.py +64 -0
- app/shared/services/validation/report_export.py +222 -0
- app/shared/services/validation/resolver.py +180 -0
- app/shared/services/validation/service.py +418 -0
- app/shared/services/validation/types.py +238 -0
- app/shared/services/validation/validators/__init__.py +36 -0
- app/shared/services/validation/validators/adapter.py +182 -0
- app/shared/services/validation/validators/base.py +332 -0
- app/shared/services/validation/validators/composite.py +233 -0
- app/shared/services/validation/validators/date_logic.py +276 -0
- app/start_server.py +133 -0
- precis_cli-0.1.3.dist-info/METADATA +180 -0
- precis_cli-0.1.3.dist-info/RECORD +442 -0
- precis_cli-0.1.3.dist-info/WHEEL +5 -0
- precis_cli-0.1.3.dist-info/entry_points.txt +4 -0
- precis_cli-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Precis Team
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
# backend/app/shared/services/validation/executor.py
|
|
17
|
+
"""
|
|
18
|
+
@fileoverview 全量校验执行器
|
|
19
|
+
|
|
20
|
+
功能概述:
|
|
21
|
+
- 提供 CLI 和前端共享的核心校验编排逻辑
|
|
22
|
+
- 编排加载→格式解析→约束校验→结果后处理全流程
|
|
23
|
+
- 支持超时控制、设置覆盖、表过滤等执行选项
|
|
24
|
+
- 提供 ValidationOptions 配置类和 create_executor 工厂函数
|
|
25
|
+
|
|
26
|
+
架构设计:
|
|
27
|
+
- 组合模式: ValidationExecutor 组合使用 DataSourceResolver + DataLoader
|
|
28
|
+
- 职责分离: 自身仅负责流程编排,路径解析和数据加载委托给子模块
|
|
29
|
+
- 调用方: CLI 的 DataValidator 和前端 API 的 validate_v2_full
|
|
30
|
+
|
|
31
|
+
输入示例:
|
|
32
|
+
executor = create_executor("project.precis.yaml")
|
|
33
|
+
options = ValidationOptions(timeout_seconds=30, strict_mode=False)
|
|
34
|
+
result = executor.execute(data_directory="./data", options=options)
|
|
35
|
+
|
|
36
|
+
输出示例:
|
|
37
|
+
result = {
|
|
38
|
+
"success": True,
|
|
39
|
+
"errors": [...],
|
|
40
|
+
"parsed_datasets": {...}
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import time
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import pandas as pd
|
|
53
|
+
|
|
54
|
+
from app.shared.core.project.loader import LoadedProject, load_project
|
|
55
|
+
from app.shared.core.project.schema.types import TableSchemaFile
|
|
56
|
+
from app.shared.domain.dataset_schema import DataSetSchema
|
|
57
|
+
|
|
58
|
+
from .chunked_loader import ChunkedDataLoader
|
|
59
|
+
from .data_loader import DataLoader
|
|
60
|
+
from .engine import execute_dag_if_needed, validate_constraints, validate_full_dataset
|
|
61
|
+
from .memory_monitor import DEFAULT_CHUNK_THRESHOLD_MB, MemoryMonitor
|
|
62
|
+
from .postprocess import (
|
|
63
|
+
attach_source_info,
|
|
64
|
+
build_id_to_name_map,
|
|
65
|
+
build_table_source_map,
|
|
66
|
+
map_table_id,
|
|
67
|
+
postprocess_result,
|
|
68
|
+
)
|
|
69
|
+
from .progress import ProgressEvent
|
|
70
|
+
from .resolver import DataSourceResolver
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
# 进度回调类型:接收 ProgressEvent,无返回值
|
|
75
|
+
ProgressCallback = Callable[[ProgressEvent], None]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ValidationOptions:
|
|
79
|
+
"""
|
|
80
|
+
@classdesc 校验执行选项
|
|
81
|
+
|
|
82
|
+
用于配置校验执行的可选参数,支持前端覆盖配置。
|
|
83
|
+
包含超时控制、错误处理策略、严格模式、表过滤等设置。
|
|
84
|
+
|
|
85
|
+
属性:
|
|
86
|
+
timeout_seconds: 超时时间(秒),默认 30 秒
|
|
87
|
+
error_handling: 错误处理策略,"continue" 表示遇到错误继续执行
|
|
88
|
+
strict_mode: 严格模式,True 时任何错误都视为校验失败
|
|
89
|
+
allow_unsafe_eval: 是否允许不安全脚本执行,None 表示使用项目配置
|
|
90
|
+
table_filter: 表过滤列表,只校验指定表相关的约束
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
timeout_seconds: int = 30,
|
|
96
|
+
error_handling: str = "continue",
|
|
97
|
+
strict_mode: bool = False,
|
|
98
|
+
allow_unsafe_eval: bool | None = None,
|
|
99
|
+
table_filter: str | list[str] | None = None,
|
|
100
|
+
chunk_threshold_mb: float = DEFAULT_CHUNK_THRESHOLD_MB,
|
|
101
|
+
chunk_rows: int = 100_000,
|
|
102
|
+
):
|
|
103
|
+
self.timeout_seconds = timeout_seconds
|
|
104
|
+
self.error_handling = error_handling
|
|
105
|
+
self.strict_mode = strict_mode
|
|
106
|
+
self.allow_unsafe_eval = allow_unsafe_eval
|
|
107
|
+
self.table_filter = table_filter
|
|
108
|
+
self.chunk_threshold_mb = chunk_threshold_mb
|
|
109
|
+
self.chunk_rows = chunk_rows
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ValidationExecutor:
|
|
113
|
+
"""
|
|
114
|
+
@classdesc 全量校验执行器
|
|
115
|
+
|
|
116
|
+
负责:
|
|
117
|
+
- 加载项目配置和 Schema 定义
|
|
118
|
+
- 委托 DataSourceResolver 解析数据源路径
|
|
119
|
+
- 委托 DataLoader 批量加载数据文件
|
|
120
|
+
- 编排格式解析和约束校验
|
|
121
|
+
- 结果后处理(ID→Name 映射)
|
|
122
|
+
- 超时控制
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
manifest_path: str,
|
|
128
|
+
settings_override: Any = None,
|
|
129
|
+
allow_unsafe_eval: bool | None = None,
|
|
130
|
+
):
|
|
131
|
+
# 校验清单文件存在性
|
|
132
|
+
# 【防御性编程】在初始化阶段即检查文件是否存在,避免后续操作失败
|
|
133
|
+
if not os.path.exists(manifest_path):
|
|
134
|
+
raise FileNotFoundError(f"项目配置文件未找到: {manifest_path}")
|
|
135
|
+
|
|
136
|
+
# 解析项目根目录并加载项目配置
|
|
137
|
+
# 【数据流】manifest_path → project_root → load_project → LoadedProject
|
|
138
|
+
# 通过依赖注入把 build_dataset_schema 传入 core 的 load_project,
|
|
139
|
+
# 避免 core 层反向依赖 services(见 loader_parts/main.py 的 schema_builder 参数)。
|
|
140
|
+
self.project_root = os.path.dirname(manifest_path)
|
|
141
|
+
from app.shared.services.project_loader import build_dataset_schema
|
|
142
|
+
|
|
143
|
+
self.loaded_project: LoadedProject = load_project(manifest_path, schema_builder=build_dataset_schema)
|
|
144
|
+
self.dataset_schema: DataSetSchema = self.loaded_project.dataset_schema
|
|
145
|
+
self.settings = self.loaded_project.manifest.settings
|
|
146
|
+
self.manifest = self.loaded_project.manifest
|
|
147
|
+
|
|
148
|
+
# P0-3: 为每个运行时约束附加其来源约束文件路径(相对 manifest 目录)。
|
|
149
|
+
# 独立约束文件为其 YAML 路径,schema 内嵌约束为 schema 文件路径;
|
|
150
|
+
# 模板展开产物与无来源约束保持无该属性(校验错误中 constraint_file 为 null)。
|
|
151
|
+
# getattr 防御:load_project 被 mock 的测试边界可能不带该新字段
|
|
152
|
+
_source_map = getattr(self.loaded_project, "constraint_source_files", None) or {}
|
|
153
|
+
for _constraint in getattr(self.dataset_schema, "constraints", None) or []:
|
|
154
|
+
_cid = getattr(_constraint, "constraint_id", None)
|
|
155
|
+
if _cid is not None and _cid in _source_map:
|
|
156
|
+
_constraint.constraint_file = _source_map[_cid] # type: ignore[attr-defined]
|
|
157
|
+
|
|
158
|
+
# 执行器级别的脚本执行权限覆盖,优先级高于项目配置
|
|
159
|
+
self.allow_unsafe_eval = allow_unsafe_eval
|
|
160
|
+
|
|
161
|
+
# 应用前端或 CLI 传入的设置覆盖值
|
|
162
|
+
# 【配置优先级】settings_override > 项目配置 > 默认值
|
|
163
|
+
if settings_override:
|
|
164
|
+
self._apply_settings_override(settings_override)
|
|
165
|
+
|
|
166
|
+
# 构建表 ID 到 Schema 文件的映射,便于快速查找
|
|
167
|
+
self._schema_by_id: dict[str, TableSchemaFile] = dict(self.loaded_project.schema_files)
|
|
168
|
+
|
|
169
|
+
# 初始化数据源解析器,负责将相对路径解析为绝对路径
|
|
170
|
+
self._resolver = DataSourceResolver(
|
|
171
|
+
project_root=self.project_root,
|
|
172
|
+
manifest=self.manifest,
|
|
173
|
+
schema_by_id=self._schema_by_id,
|
|
174
|
+
)
|
|
175
|
+
# 初始化数据加载器,负责批量加载数据文件
|
|
176
|
+
self._data_loader = DataLoader(
|
|
177
|
+
resolver=self._resolver,
|
|
178
|
+
dataset_schema=self.dataset_schema,
|
|
179
|
+
schema_by_id=self._schema_by_id,
|
|
180
|
+
settings=self.settings,
|
|
181
|
+
)
|
|
182
|
+
# 初始化内存监控器
|
|
183
|
+
self._memory_monitor = MemoryMonitor()
|
|
184
|
+
# 初始化分块数据加载器(惰性创建,仅在需要时使用)
|
|
185
|
+
self._chunked_loader: ChunkedDataLoader | None = None
|
|
186
|
+
|
|
187
|
+
def _get_chunked_loader(self, options: ValidationOptions) -> ChunkedDataLoader:
|
|
188
|
+
"""获取或创建分块数据加载器。"""
|
|
189
|
+
if self._chunked_loader is None:
|
|
190
|
+
self._memory_monitor.chunk_threshold_mb = options.chunk_threshold_mb
|
|
191
|
+
self._memory_monitor.chunk_rows = options.chunk_rows
|
|
192
|
+
self._chunked_loader = ChunkedDataLoader(
|
|
193
|
+
resolver=self._resolver,
|
|
194
|
+
dataset_schema=self.dataset_schema,
|
|
195
|
+
schema_by_id=self._schema_by_id,
|
|
196
|
+
settings=self.settings,
|
|
197
|
+
memory_monitor=self._memory_monitor,
|
|
198
|
+
)
|
|
199
|
+
return self._chunked_loader
|
|
200
|
+
|
|
201
|
+
def _apply_settings_override(self, override: Any) -> None:
|
|
202
|
+
"""
|
|
203
|
+
@methoddesc 应用设置覆盖
|
|
204
|
+
|
|
205
|
+
将前端或 CLI 传入的设置覆盖值应用到项目配置中。
|
|
206
|
+
支持 Pydantic 模型和字典两种格式的覆盖值。
|
|
207
|
+
|
|
208
|
+
参数:
|
|
209
|
+
override: 设置覆盖值,可以是 Pydantic 模型或字典
|
|
210
|
+
|
|
211
|
+
异常:
|
|
212
|
+
TypeError: override 类型不支持(非 dict 非 Pydantic 模型)
|
|
213
|
+
ValueError: 覆盖值包含未知字段
|
|
214
|
+
"""
|
|
215
|
+
if override is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# 统一转换为字典格式
|
|
219
|
+
if hasattr(override, "model_dump"):
|
|
220
|
+
override_dict = override.model_dump(exclude_none=True)
|
|
221
|
+
elif isinstance(override, dict):
|
|
222
|
+
override_dict = override
|
|
223
|
+
else:
|
|
224
|
+
raise TypeError("settings_override 必须为 dict 或 Pydantic 模型")
|
|
225
|
+
|
|
226
|
+
# 校验根级别字段是否合法
|
|
227
|
+
allowed_root = {"validation", "file_processing", "script_security"}
|
|
228
|
+
unknown_root = set(override_dict.keys()) - allowed_root
|
|
229
|
+
if unknown_root:
|
|
230
|
+
raise ValueError(f"settings_override 包含未知字段: {sorted(unknown_root)}")
|
|
231
|
+
|
|
232
|
+
# 逐组应用覆盖值
|
|
233
|
+
for group_name in ("validation", "file_processing", "script_security"):
|
|
234
|
+
group_override = override_dict.get(group_name)
|
|
235
|
+
if not group_override:
|
|
236
|
+
continue
|
|
237
|
+
if not isinstance(group_override, dict):
|
|
238
|
+
raise ValueError(f"settings_override.{group_name} 必须为对象")
|
|
239
|
+
target = getattr(self.settings, group_name, None)
|
|
240
|
+
if target is None:
|
|
241
|
+
continue
|
|
242
|
+
for key, value in group_override.items():
|
|
243
|
+
if not hasattr(target, key):
|
|
244
|
+
raise ValueError(f"settings_override.{group_name} 包含未知字段: {key}")
|
|
245
|
+
setattr(target, key, value)
|
|
246
|
+
|
|
247
|
+
def _resolve_allow_unsafe_eval(self, options: ValidationOptions) -> bool:
|
|
248
|
+
"""
|
|
249
|
+
@methoddesc 解析本次校验是否允许不安全脚本执行
|
|
250
|
+
|
|
251
|
+
优先级(高到低):
|
|
252
|
+
1. 请求级选项 options.allow_unsafe_eval
|
|
253
|
+
2. 执行器实例级 self.allow_unsafe_eval
|
|
254
|
+
3. 项目配置:settings.script_security.allow_eval 且非沙箱模式
|
|
255
|
+
|
|
256
|
+
参数:
|
|
257
|
+
options: 当前校验执行选项
|
|
258
|
+
|
|
259
|
+
返回:
|
|
260
|
+
True 表示允许执行使用 eval 的脚本化约束
|
|
261
|
+
"""
|
|
262
|
+
if options.allow_unsafe_eval is not None:
|
|
263
|
+
return options.allow_unsafe_eval
|
|
264
|
+
if self.allow_unsafe_eval is not None:
|
|
265
|
+
return self.allow_unsafe_eval
|
|
266
|
+
script_security = getattr(self.settings, "script_security", None)
|
|
267
|
+
if script_security is None:
|
|
268
|
+
return False
|
|
269
|
+
allow_eval = getattr(script_security, "allow_eval", False)
|
|
270
|
+
sandbox_mode = getattr(script_security, "sandbox_mode", True)
|
|
271
|
+
return bool(allow_eval and not sandbox_mode)
|
|
272
|
+
|
|
273
|
+
def _build_table_source_map(self) -> dict[str, dict[str, str | None]]:
|
|
274
|
+
"""构建表 ID 到数据源信息的映射(委托 postprocess.build_table_source_map)。"""
|
|
275
|
+
return build_table_source_map(self._schema_by_id)
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _attach_source_info(item: dict[str, Any], table_source_map: dict[str, dict[str, str | None]]) -> None:
|
|
279
|
+
"""将数据源信息附加到条目字典(委托 postprocess.attach_source_info)。"""
|
|
280
|
+
attach_source_info(item, table_source_map)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _build_id_to_name_map(dataset_schema: DataSetSchema) -> dict[str, str]:
|
|
284
|
+
"""构建表 ID 到显示名称的映射(委托 postprocess.build_id_to_name_map)。"""
|
|
285
|
+
return build_id_to_name_map(dataset_schema)
|
|
286
|
+
|
|
287
|
+
def _postprocess_result(self, result: dict[str, Any]) -> None:
|
|
288
|
+
"""结果统一后处理(委托 postprocess.postprocess_result)。"""
|
|
289
|
+
postprocess_result(result, self.dataset_schema, self._schema_by_id)
|
|
290
|
+
|
|
291
|
+
def _finalize_result(self, result: dict[str, Any], started: float) -> None:
|
|
292
|
+
result["duration_ms"] = int((time.monotonic() - started) * 1000)
|
|
293
|
+
result["memory_info"] = self._memory_monitor.get_progress_info()
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _emit_progress(
|
|
297
|
+
callback: ProgressCallback | None,
|
|
298
|
+
started: float,
|
|
299
|
+
stage: str,
|
|
300
|
+
table: str | None = None,
|
|
301
|
+
chunk_index: int = 0,
|
|
302
|
+
chunk_total: int = 0,
|
|
303
|
+
rows_done: int = 0,
|
|
304
|
+
rows_total: int = 0,
|
|
305
|
+
errors_so_far: int = 0,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""安全地触发进度回调。
|
|
308
|
+
|
|
309
|
+
callback 为 None 时直接返回(零开销);callback 抛异常时仅记日志、不抛出,
|
|
310
|
+
确保进度通道的故障不影响校验主流程。所有字段口径与 ProgressEvent 文档一致。
|
|
311
|
+
|
|
312
|
+
参数:
|
|
313
|
+
callback: 调用方传入的进度回调,可为 None。
|
|
314
|
+
started: 校验开始时间(time.monotonic() 返回值),用于计算 elapsed_ms。
|
|
315
|
+
stage/table/chunk_index/...: 见 ProgressEvent 字段说明。
|
|
316
|
+
"""
|
|
317
|
+
if callback is None:
|
|
318
|
+
return
|
|
319
|
+
try:
|
|
320
|
+
callback(
|
|
321
|
+
ProgressEvent(
|
|
322
|
+
stage=stage,
|
|
323
|
+
table=table,
|
|
324
|
+
chunk_index=chunk_index,
|
|
325
|
+
chunk_total=chunk_total,
|
|
326
|
+
rows_done=rows_done,
|
|
327
|
+
rows_total=rows_total,
|
|
328
|
+
errors_so_far=errors_so_far,
|
|
329
|
+
elapsed_ms=int((time.monotonic() - started) * 1000),
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
except Exception: # noqa: BLE001 — 进度通道异常不得影响校验
|
|
333
|
+
logger.exception("progress_callback 抛出异常,已忽略")
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _map_table_id(item: dict[str, Any], id_to_name: dict[str, str]) -> None:
|
|
337
|
+
"""将条目中的表 ID 替换为表显示名称(委托 postprocess.map_table_id)。"""
|
|
338
|
+
map_table_id(item, id_to_name)
|
|
339
|
+
|
|
340
|
+
def execute(
|
|
341
|
+
self,
|
|
342
|
+
data_directory: str,
|
|
343
|
+
options: ValidationOptions | None = None,
|
|
344
|
+
progress_callback: ProgressCallback | None = None,
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""
|
|
347
|
+
@methoddesc 执行完整校验流程
|
|
348
|
+
|
|
349
|
+
编排数据加载、格式解析、约束校验和结果后处理的完整流程。
|
|
350
|
+
采用分阶段执行策略,每个阶段后检查超时条件。
|
|
351
|
+
当检测到大文件时自动切换到分块处理模式。
|
|
352
|
+
|
|
353
|
+
执行流程:
|
|
354
|
+
Step 1: 检测文件大小,决定使用标准模式还是分块模式
|
|
355
|
+
Step 2: 加载数据源(标准 DataLoader 或 ChunkedDataLoader)
|
|
356
|
+
Step 3: 检查加载阶段超时
|
|
357
|
+
Step 4: 检查数据加载成功
|
|
358
|
+
Step 5: 确定脚本安全执行策略
|
|
359
|
+
Step 6: 执行格式解析和约束校验
|
|
360
|
+
Step 7: 结果后处理(ID→Name 映射 + 数据源信息附加)
|
|
361
|
+
Step 8: 检查校验阶段超时
|
|
362
|
+
|
|
363
|
+
参数:
|
|
364
|
+
data_directory: 数据文件所在目录
|
|
365
|
+
options: 校验执行选项,包含超时、过滤等配置
|
|
366
|
+
progress_callback: 可选的进度回调,校验过程中在阶段切换/分块边界
|
|
367
|
+
触发。callback 异常不影响校验(仅记日志)。不传时行为完全不变。
|
|
368
|
+
|
|
369
|
+
返回:
|
|
370
|
+
包含以下字段的字典:
|
|
371
|
+
- raw_datasets: 原始数据集
|
|
372
|
+
- parsed_datasets: 解析后的数据集
|
|
373
|
+
- errors: 校验错误列表
|
|
374
|
+
- loading_errors: 数据加载错误列表
|
|
375
|
+
- duration_ms: 执行耗时(毫秒)
|
|
376
|
+
- timeout_occurred: 是否发生超时
|
|
377
|
+
- validation_details: 校验详情
|
|
378
|
+
- chunked_mode: 是否使用了分块模式
|
|
379
|
+
- memory_info: 内存监控信息
|
|
380
|
+
"""
|
|
381
|
+
if options is None:
|
|
382
|
+
options = ValidationOptions()
|
|
383
|
+
|
|
384
|
+
# 更新内存监控器配置
|
|
385
|
+
self._memory_monitor.chunk_threshold_mb = options.chunk_threshold_mb
|
|
386
|
+
self._memory_monitor.chunk_rows = options.chunk_rows
|
|
387
|
+
|
|
388
|
+
# 记录开始时间,用于计算总耗时和超时检查
|
|
389
|
+
started = time.monotonic()
|
|
390
|
+
result: dict[str, Any] = {
|
|
391
|
+
"raw_datasets": {},
|
|
392
|
+
"parsed_datasets": {},
|
|
393
|
+
"errors": [],
|
|
394
|
+
"loading_errors": [],
|
|
395
|
+
"duration_ms": 0,
|
|
396
|
+
"timeout_occurred": False,
|
|
397
|
+
"interrupted": False, # C6 遇错即停:是否因 error_handling=stop 提前终止
|
|
398
|
+
"validation_details": {"format_checks": [], "constraint_checks": []},
|
|
399
|
+
"chunked_mode": False,
|
|
400
|
+
"memory_info": {},
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Step 1: 检测是否需要分块模式
|
|
404
|
+
use_chunked = self._should_use_chunked_mode(data_directory, options)
|
|
405
|
+
if use_chunked:
|
|
406
|
+
result["chunked_mode"] = True
|
|
407
|
+
logger.info("检测到大文件,启用分块处理模式")
|
|
408
|
+
return self._execute_chunked(data_directory, options, started, result, progress_callback)
|
|
409
|
+
|
|
410
|
+
# Step 2-8: 标准模式流水线(加载→校验→后处理→超时检查)
|
|
411
|
+
return self._execute_standard(data_directory, options, started, result, progress_callback)
|
|
412
|
+
|
|
413
|
+
def _execute_standard(
|
|
414
|
+
self,
|
|
415
|
+
data_directory: str,
|
|
416
|
+
options: ValidationOptions,
|
|
417
|
+
started: float,
|
|
418
|
+
result: dict[str, Any],
|
|
419
|
+
progress_callback: ProgressCallback | None = None,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
"""
|
|
422
|
+
@methoddesc 执行标准(非分块)模式校验流水线
|
|
423
|
+
|
|
424
|
+
承接 execute 的 Step 2-8:数据加载、加载阶段超时与空数据检查、
|
|
425
|
+
格式解析与约束校验、结果后处理、校验阶段超时检查。
|
|
426
|
+
|
|
427
|
+
参数:
|
|
428
|
+
data_directory: 数据文件所在目录
|
|
429
|
+
options: 校验执行选项
|
|
430
|
+
started: 开始时间(time.monotonic())
|
|
431
|
+
result: 已初始化的结果字典(chunked_mode=False)
|
|
432
|
+
progress_callback: 可选进度回调
|
|
433
|
+
|
|
434
|
+
返回:
|
|
435
|
+
完整的校验结果字典
|
|
436
|
+
"""
|
|
437
|
+
# Step 2: 加载数据源(标准模式)
|
|
438
|
+
raw_datasets, loading_errors = self._data_loader.load_data_sources(
|
|
439
|
+
data_directory, table_filter=options.table_filter
|
|
440
|
+
)
|
|
441
|
+
result["raw_datasets"] = raw_datasets
|
|
442
|
+
result["loading_errors"] = loading_errors
|
|
443
|
+
|
|
444
|
+
# 追加项目加载阶段的错误
|
|
445
|
+
if self.loaded_project.loading_errors:
|
|
446
|
+
for err in self.loaded_project.loading_errors:
|
|
447
|
+
result["loading_errors"].append(err.to_dict())
|
|
448
|
+
|
|
449
|
+
result["warnings"] = self.loaded_project.warnings
|
|
450
|
+
|
|
451
|
+
# 进度:数据加载完成(标准模式一锤子加载,rows_total 为各表行数之和)
|
|
452
|
+
self._emit_progress(
|
|
453
|
+
progress_callback,
|
|
454
|
+
started,
|
|
455
|
+
stage="loading",
|
|
456
|
+
table=None,
|
|
457
|
+
rows_done=0,
|
|
458
|
+
rows_total=sum(len(df) for df in raw_datasets.values()),
|
|
459
|
+
errors_so_far=len(result["loading_errors"]),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Step 3: 检查加载阶段是否超时
|
|
463
|
+
if (time.monotonic() - started) > options.timeout_seconds:
|
|
464
|
+
result["errors"].append(
|
|
465
|
+
{"error_type": "Timeout", "message": f"数据加载阶段超时(>{options.timeout_seconds}s)"}
|
|
466
|
+
)
|
|
467
|
+
result["timeout_occurred"] = True
|
|
468
|
+
self._emit_progress(
|
|
469
|
+
progress_callback,
|
|
470
|
+
started,
|
|
471
|
+
stage="done",
|
|
472
|
+
errors_so_far=len(result["errors"]),
|
|
473
|
+
)
|
|
474
|
+
self._finalize_result(result, started)
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
# Step 4: 检查数据是否加载成功
|
|
478
|
+
if not raw_datasets:
|
|
479
|
+
result["errors"].append(
|
|
480
|
+
{"error_type": "DataLoadingError", "message": "未能从数据目录加载任何数据表,校验中止。"}
|
|
481
|
+
)
|
|
482
|
+
self._emit_progress(
|
|
483
|
+
progress_callback,
|
|
484
|
+
started,
|
|
485
|
+
stage="done",
|
|
486
|
+
errors_so_far=len(result["errors"]),
|
|
487
|
+
)
|
|
488
|
+
self._finalize_result(result, started)
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
# 标准模式总行数(用于进度事件)
|
|
492
|
+
std_rows_total = sum(len(df) for df in raw_datasets.values())
|
|
493
|
+
|
|
494
|
+
# Step 5: 确定脚本安全执行策略
|
|
495
|
+
allow_unsafe_eval = self._resolve_allow_unsafe_eval(options)
|
|
496
|
+
|
|
497
|
+
logger.debug(f"Starting validate_full_dataset with {len(raw_datasets)} datasets")
|
|
498
|
+
|
|
499
|
+
# 计算校验阶段的超时截止时间
|
|
500
|
+
deadline = started + options.timeout_seconds
|
|
501
|
+
|
|
502
|
+
# 进度:进入校验阶段(标准模式 validate_full_dataset 为一锤子调用,粗粒度)
|
|
503
|
+
self._emit_progress(
|
|
504
|
+
progress_callback,
|
|
505
|
+
started,
|
|
506
|
+
stage="validating",
|
|
507
|
+
table=None,
|
|
508
|
+
rows_done=0,
|
|
509
|
+
rows_total=std_rows_total,
|
|
510
|
+
errors_so_far=len(result["errors"]),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Step 6: 执行格式解析和约束校验
|
|
514
|
+
# C6 遇错即停:error_handling="stop" 时,发现首个错误即停止剩余校验
|
|
515
|
+
stop_on_first_error = options.error_handling == "stop"
|
|
516
|
+
try:
|
|
517
|
+
parsed_datasets, validation_errors, validation_details = validate_full_dataset(
|
|
518
|
+
raw_datasets,
|
|
519
|
+
self.dataset_schema,
|
|
520
|
+
allow_unsafe_eval=allow_unsafe_eval,
|
|
521
|
+
table_filter=options.table_filter,
|
|
522
|
+
transform_files=getattr(self.loaded_project, "transform_files", None) if self.loaded_project else None,
|
|
523
|
+
regex_files=getattr(self.loaded_project, "regex_node_files", None) if self.loaded_project else None,
|
|
524
|
+
deadline=deadline,
|
|
525
|
+
stop_on_first_error=stop_on_first_error,
|
|
526
|
+
)
|
|
527
|
+
except Exception as e:
|
|
528
|
+
logger.exception(f"Error during validate_full_dataset: {e}")
|
|
529
|
+
raise
|
|
530
|
+
result["parsed_datasets"] = parsed_datasets
|
|
531
|
+
result["errors"].extend(validation_errors)
|
|
532
|
+
result["validation_details"] = validation_details
|
|
533
|
+
# C6: 若校验因 stop 提前终止(返回了 ValidationInterrupted 标记),记录中断状态
|
|
534
|
+
if stop_on_first_error and any(e.get("error_type") == "ValidationInterrupted" for e in validation_errors):
|
|
535
|
+
result["interrupted"] = True
|
|
536
|
+
|
|
537
|
+
# 进度:校验执行完成(更新累计错误数)
|
|
538
|
+
self._emit_progress(
|
|
539
|
+
progress_callback,
|
|
540
|
+
started,
|
|
541
|
+
stage="validating",
|
|
542
|
+
table=None,
|
|
543
|
+
rows_done=std_rows_total,
|
|
544
|
+
rows_total=std_rows_total,
|
|
545
|
+
errors_so_far=len(result["errors"]),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Step 7: 结果后处理
|
|
549
|
+
self._postprocess_result(result)
|
|
550
|
+
|
|
551
|
+
# Step 8: 检查校验阶段是否超时
|
|
552
|
+
if (time.monotonic() - started) > options.timeout_seconds:
|
|
553
|
+
result["errors"].append(
|
|
554
|
+
{"error_type": "Timeout", "message": f"数据校验阶段超时(>{options.timeout_seconds}s)"}
|
|
555
|
+
)
|
|
556
|
+
result["timeout_occurred"] = True
|
|
557
|
+
|
|
558
|
+
# 进度:全部完成
|
|
559
|
+
self._emit_progress(
|
|
560
|
+
progress_callback,
|
|
561
|
+
started,
|
|
562
|
+
stage="done",
|
|
563
|
+
table=None,
|
|
564
|
+
rows_done=std_rows_total,
|
|
565
|
+
rows_total=std_rows_total,
|
|
566
|
+
errors_so_far=len(result["errors"]),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
self._finalize_result(result, started)
|
|
570
|
+
return result
|
|
571
|
+
|
|
572
|
+
def _should_use_chunked_mode(self, data_directory: str, options: ValidationOptions) -> bool:
|
|
573
|
+
"""
|
|
574
|
+
@methoddesc 检测数据目录中是否有需要分块处理的大文件
|
|
575
|
+
|
|
576
|
+
参数:
|
|
577
|
+
data_directory: 数据文件目录
|
|
578
|
+
options: 校验选项
|
|
579
|
+
|
|
580
|
+
返回:
|
|
581
|
+
True 表示存在需要分块处理的大文件
|
|
582
|
+
"""
|
|
583
|
+
search_directory = data_directory
|
|
584
|
+
first_data_source = self._resolver.resolve_first_data_source()
|
|
585
|
+
if first_data_source:
|
|
586
|
+
search_directory = first_data_source
|
|
587
|
+
|
|
588
|
+
if not os.path.isdir(search_directory):
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
for table_id, schema_file in self._schema_by_id.items():
|
|
592
|
+
source_path, _ = self._resolver.resolve_source_path(data_directory, schema_file)
|
|
593
|
+
if source_path and self._memory_monitor.should_chunk(source_path):
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
def _execute_chunked(
|
|
599
|
+
self,
|
|
600
|
+
data_directory: str,
|
|
601
|
+
options: ValidationOptions,
|
|
602
|
+
started: float,
|
|
603
|
+
result: dict[str, Any],
|
|
604
|
+
progress_callback: ProgressCallback | None = None,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""
|
|
607
|
+
@methoddesc 执行分块校验流程
|
|
608
|
+
|
|
609
|
+
使用 ChunkedDataLoader 分块加载数据,逐块执行格式解析和约束校验,
|
|
610
|
+
最终合并所有分块的结果。分块循环内按块边界触发进度回调(sparkline 主数据源)。
|
|
611
|
+
|
|
612
|
+
参数:
|
|
613
|
+
data_directory: 数据文件目录
|
|
614
|
+
options: 校验选项
|
|
615
|
+
started: 开始时间
|
|
616
|
+
result: 结果字典(已初始化)
|
|
617
|
+
progress_callback: 可选进度回调;不传时行为完全不变。
|
|
618
|
+
|
|
619
|
+
返回:
|
|
620
|
+
完整的校验结果字典
|
|
621
|
+
"""
|
|
622
|
+
chunked_loader = self._get_chunked_loader(options)
|
|
623
|
+
|
|
624
|
+
# 分块加载数据源
|
|
625
|
+
try:
|
|
626
|
+
chunked_datasets, chunked_loading_errors = chunked_loader.load_chunked_sources(
|
|
627
|
+
data_directory, table_filter=options.table_filter
|
|
628
|
+
)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
logger.exception(f"分块加载失败: {e}")
|
|
631
|
+
result["errors"].append({"error_type": "ChunkedLoadError", "message": f"分块加载失败: {e}"})
|
|
632
|
+
self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
|
|
633
|
+
self._finalize_result(result, started)
|
|
634
|
+
return result
|
|
635
|
+
|
|
636
|
+
# 追加项目加载阶段的错误
|
|
637
|
+
if self.loaded_project.loading_errors:
|
|
638
|
+
for proj_err in self.loaded_project.loading_errors:
|
|
639
|
+
result["loading_errors"].append(proj_err.to_dict())
|
|
640
|
+
# 回归 #5: 追加分块加载阶段单表级别的错误(源找不到/加载异常),否则损坏表静默消失、
|
|
641
|
+
# 报告显示"全部通过"。这与标准模式 data_loader 返回 loading_errors 的语义对齐。
|
|
642
|
+
for chunk_err in chunked_loading_errors:
|
|
643
|
+
result["loading_errors"].append(chunk_err)
|
|
644
|
+
result["warnings"] = self.loaded_project.warnings
|
|
645
|
+
|
|
646
|
+
if not chunked_datasets:
|
|
647
|
+
result["errors"].append(
|
|
648
|
+
{"error_type": "DataLoadingError", "message": "未能从数据目录加载任何数据表,校验中止。"}
|
|
649
|
+
)
|
|
650
|
+
self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
|
|
651
|
+
self._finalize_result(result, started)
|
|
652
|
+
return result
|
|
653
|
+
|
|
654
|
+
# 统计总行数和分块数
|
|
655
|
+
total_rows = 0
|
|
656
|
+
total_chunks = 0
|
|
657
|
+
for table_id, chunks in chunked_datasets.items():
|
|
658
|
+
for chunk in chunks:
|
|
659
|
+
total_rows += len(chunk)
|
|
660
|
+
total_chunks += len(chunks)
|
|
661
|
+
|
|
662
|
+
logger.info(f"分块加载完成: {len(chunked_datasets)} 个表, {total_chunks} 个分块, {total_rows} 总行数")
|
|
663
|
+
|
|
664
|
+
# 记录原始数据集信息(用于响应)
|
|
665
|
+
for table_id, chunks in chunked_datasets.items():
|
|
666
|
+
result["raw_datasets"][table_id] = {"chunk_count": len(chunks), "row_count": sum(len(c) for c in chunks)}
|
|
667
|
+
|
|
668
|
+
# 进度:分块加载完成(loading 阶段汇总)
|
|
669
|
+
self._emit_progress(
|
|
670
|
+
progress_callback,
|
|
671
|
+
started,
|
|
672
|
+
stage="loading",
|
|
673
|
+
table=None,
|
|
674
|
+
chunk_total=total_chunks,
|
|
675
|
+
rows_total=total_rows,
|
|
676
|
+
errors_so_far=len(result["loading_errors"]),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# 检查加载阶段超时
|
|
680
|
+
if (time.monotonic() - started) > options.timeout_seconds:
|
|
681
|
+
result["errors"].append(
|
|
682
|
+
{"error_type": "Timeout", "message": f"数据加载阶段超时(>{options.timeout_seconds}s)"}
|
|
683
|
+
)
|
|
684
|
+
result["timeout_occurred"] = True
|
|
685
|
+
self._emit_progress(progress_callback, started, stage="done", errors_so_far=len(result["errors"]))
|
|
686
|
+
self._finalize_result(result, started)
|
|
687
|
+
return result
|
|
688
|
+
|
|
689
|
+
# 确定脚本安全策略
|
|
690
|
+
allow_unsafe_eval = self._resolve_allow_unsafe_eval(options)
|
|
691
|
+
deadline = started + options.timeout_seconds
|
|
692
|
+
# C6 遇错即停:error_handling="stop" 时,分块格式校验发现首个错误即停止后续分块
|
|
693
|
+
stop_on_first_error = options.error_handling == "stop"
|
|
694
|
+
|
|
695
|
+
# 逐块执行格式解析(跳过约束),聚合各块结果
|
|
696
|
+
all_parsed_datasets, all_errors, all_validation_details, chunk_interrupted = self._parse_chunks_loop(
|
|
697
|
+
chunked_datasets,
|
|
698
|
+
options=options,
|
|
699
|
+
started=started,
|
|
700
|
+
total_rows=total_rows,
|
|
701
|
+
total_chunks=total_chunks,
|
|
702
|
+
allow_unsafe_eval=allow_unsafe_eval,
|
|
703
|
+
deadline=deadline,
|
|
704
|
+
stop_on_first_error=stop_on_first_error,
|
|
705
|
+
progress_callback=progress_callback,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# 合并分块解析结果并对全量数据执行 Transform DAG 与约束校验
|
|
709
|
+
self._apply_chunked_global_validation(
|
|
710
|
+
all_parsed_datasets,
|
|
711
|
+
all_errors,
|
|
712
|
+
all_validation_details,
|
|
713
|
+
result=result,
|
|
714
|
+
options=options,
|
|
715
|
+
allow_unsafe_eval=allow_unsafe_eval,
|
|
716
|
+
deadline=deadline,
|
|
717
|
+
stop_on_first_error=stop_on_first_error,
|
|
718
|
+
chunk_interrupted=chunk_interrupted,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
# 结果后处理
|
|
722
|
+
self._postprocess_result(result)
|
|
723
|
+
|
|
724
|
+
# 检查总超时
|
|
725
|
+
if (time.monotonic() - started) > options.timeout_seconds:
|
|
726
|
+
result["timeout_occurred"] = True
|
|
727
|
+
|
|
728
|
+
# 进度:分块模式全部完成
|
|
729
|
+
self._emit_progress(
|
|
730
|
+
progress_callback,
|
|
731
|
+
started,
|
|
732
|
+
stage="done",
|
|
733
|
+
table=None,
|
|
734
|
+
chunk_total=total_chunks,
|
|
735
|
+
rows_done=total_rows,
|
|
736
|
+
rows_total=total_rows,
|
|
737
|
+
errors_so_far=len(result["errors"]),
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
self._finalize_result(result, started)
|
|
741
|
+
return result
|
|
742
|
+
|
|
743
|
+
def _parse_chunks_loop(
|
|
744
|
+
self,
|
|
745
|
+
chunked_datasets: dict[str, list[pd.DataFrame]],
|
|
746
|
+
options: ValidationOptions,
|
|
747
|
+
started: float,
|
|
748
|
+
total_rows: int,
|
|
749
|
+
total_chunks: int,
|
|
750
|
+
allow_unsafe_eval: bool,
|
|
751
|
+
deadline: float | None,
|
|
752
|
+
stop_on_first_error: bool,
|
|
753
|
+
progress_callback: ProgressCallback | None = None,
|
|
754
|
+
) -> tuple[dict[str, list[pd.DataFrame]], list[dict], dict[str, list[dict]], bool]:
|
|
755
|
+
"""
|
|
756
|
+
@methoddesc 逐块执行格式解析(跳过约束校验),聚合各块结果
|
|
757
|
+
|
|
758
|
+
【分块解析】每块只跑格式解析+派生列,跳过 Transform DAG 和约束校验;
|
|
759
|
+
行变 transform 与跨表/跨块约束都需要全局数据,由
|
|
760
|
+
_apply_chunked_global_validation 在 concat 全量后统一执行。
|
|
761
|
+
分块循环内按块边界触发进度回调(sparkline 主数据源)。
|
|
762
|
+
|
|
763
|
+
参数:
|
|
764
|
+
chunked_datasets: 表 ID → 分块 DataFrame 列表
|
|
765
|
+
options: 校验选项
|
|
766
|
+
started: 开始时间(time.monotonic())
|
|
767
|
+
total_rows/total_chunks: 总行数与总分块数(进度事件用)
|
|
768
|
+
allow_unsafe_eval: 脚本执行权限
|
|
769
|
+
deadline: 校验阶段超时截止时间
|
|
770
|
+
stop_on_first_error: C6 遇错即停
|
|
771
|
+
progress_callback: 可选进度回调
|
|
772
|
+
|
|
773
|
+
返回:
|
|
774
|
+
(按表聚合的解析结果, 聚合错误列表, 聚合校验详情, 是否因 stop 中断)
|
|
775
|
+
"""
|
|
776
|
+
all_parsed_datasets: dict[str, list[pd.DataFrame]] = {}
|
|
777
|
+
all_errors: list[dict] = []
|
|
778
|
+
all_validation_details: dict[str, list[dict]] = {
|
|
779
|
+
"format_checks": [],
|
|
780
|
+
"constraint_checks": [],
|
|
781
|
+
}
|
|
782
|
+
# C6: 跟踪是否因 stop 在分块阶段中断(中断后跳过全局约束校验)
|
|
783
|
+
chunk_interrupted = False
|
|
784
|
+
# §1.31: 超时只上报一次并终止整个分块循环(超时后剩余表本就不会再跑,
|
|
785
|
+
# 每表追加一条 Timeout 是对同一事件的重复描述)
|
|
786
|
+
timeout_hit = False
|
|
787
|
+
|
|
788
|
+
# 累计已处理行数(跨表累加,用于进度事件)
|
|
789
|
+
rows_done_so_far = 0
|
|
790
|
+
|
|
791
|
+
for table_id, chunks in chunked_datasets.items():
|
|
792
|
+
if timeout_hit:
|
|
793
|
+
break
|
|
794
|
+
all_parsed_datasets[table_id] = []
|
|
795
|
+
|
|
796
|
+
for chunk_idx, chunk_df in enumerate(chunks):
|
|
797
|
+
# 进度:开始处理当前分块(1-based chunk_index,sparkline 主要数据源)
|
|
798
|
+
self._emit_progress(
|
|
799
|
+
progress_callback,
|
|
800
|
+
started,
|
|
801
|
+
stage="validating",
|
|
802
|
+
table=table_id,
|
|
803
|
+
chunk_index=chunk_idx + 1,
|
|
804
|
+
chunk_total=total_chunks,
|
|
805
|
+
rows_done=rows_done_so_far,
|
|
806
|
+
rows_total=total_rows,
|
|
807
|
+
errors_so_far=len(all_errors),
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# 超时检查
|
|
811
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
812
|
+
if not timeout_hit:
|
|
813
|
+
logger.warning(f"分块校验超时: 表 {table_id} 分块 {chunk_idx + 1}/{len(chunks)}")
|
|
814
|
+
all_errors.append(
|
|
815
|
+
{
|
|
816
|
+
"error_type": "Timeout",
|
|
817
|
+
"stage": "constraint",
|
|
818
|
+
"check_type": "Timeout",
|
|
819
|
+
"message": "分块校验超时,剩余分块未执行",
|
|
820
|
+
"table": table_id,
|
|
821
|
+
}
|
|
822
|
+
)
|
|
823
|
+
timeout_hit = True
|
|
824
|
+
# §1.31: 中断块行数计入进度——started 进度已发出,补计保持单调,
|
|
825
|
+
# 并补发一条中断点进度事件(前端进度条跳到中断点,不回跳)
|
|
826
|
+
rows_done_so_far += len(chunk_df)
|
|
827
|
+
self._emit_progress(
|
|
828
|
+
progress_callback,
|
|
829
|
+
started,
|
|
830
|
+
stage="validating",
|
|
831
|
+
table=table_id,
|
|
832
|
+
chunk_index=chunk_idx + 1,
|
|
833
|
+
chunk_total=total_chunks,
|
|
834
|
+
rows_done=rows_done_so_far,
|
|
835
|
+
rows_total=total_rows,
|
|
836
|
+
errors_so_far=len(all_errors),
|
|
837
|
+
)
|
|
838
|
+
break
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
parsed_datasets: dict[str, pd.DataFrame]
|
|
842
|
+
validation_errors: list[dict]
|
|
843
|
+
validation_details: dict[str, list[dict]]
|
|
844
|
+
# 【分块解析】每块只跑格式解析+派生列,跳过 Transform DAG 和约束校验。
|
|
845
|
+
# 行变 transform(FilterRows/DropDuplicates)与跨表/跨块约束都需要全局数据,
|
|
846
|
+
# 在 concat 全量 parsed 后统一执行(execute_dag_if_needed + validate_constraints),
|
|
847
|
+
# 以避免分块模式下的结果错误(行变 transform 块内失效、FK 假阳性、Unique 假阴性)。
|
|
848
|
+
parsed_datasets, validation_errors, validation_details = validate_full_dataset(
|
|
849
|
+
{table_id: chunk_df},
|
|
850
|
+
self.dataset_schema,
|
|
851
|
+
allow_unsafe_eval=allow_unsafe_eval,
|
|
852
|
+
table_filter=options.table_filter,
|
|
853
|
+
# 分块解析阶段不传 transform/regex 文件,跳过 DAG
|
|
854
|
+
transform_files=None,
|
|
855
|
+
regex_files=None,
|
|
856
|
+
deadline=deadline,
|
|
857
|
+
skip_constraints=True,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# 收集解析后的数据
|
|
861
|
+
for tid, parsed_df in parsed_datasets.items():
|
|
862
|
+
all_parsed_datasets[tid].append(parsed_df)
|
|
863
|
+
|
|
864
|
+
# 聚合错误(添加分块信息)
|
|
865
|
+
for chunk_err in validation_errors:
|
|
866
|
+
chunk_err["chunk_index"] = chunk_idx
|
|
867
|
+
all_errors.append(chunk_err)
|
|
868
|
+
|
|
869
|
+
# 聚合校验详情
|
|
870
|
+
for check in validation_details.get("format_checks", []):
|
|
871
|
+
all_validation_details["format_checks"].append(check)
|
|
872
|
+
for check in validation_details.get("constraint_checks", []):
|
|
873
|
+
all_validation_details["constraint_checks"].append(check)
|
|
874
|
+
|
|
875
|
+
# C6 遇错即停:分块模式下,当前块格式校验发现错误即停止后续分块(块间中断,
|
|
876
|
+
# 避免半块数据)。块内已跑完,数据完整。
|
|
877
|
+
if stop_on_first_error and validation_errors:
|
|
878
|
+
all_errors.append(
|
|
879
|
+
{
|
|
880
|
+
"error_type": "ValidationInterrupted",
|
|
881
|
+
"stage": "format",
|
|
882
|
+
"check_type": "ValidationInterrupted",
|
|
883
|
+
"table": table_id,
|
|
884
|
+
"message": (
|
|
885
|
+
f"已按项目设置『遇错即停』停止:表 '{table_id}' 分块 {chunk_idx + 1} "
|
|
886
|
+
f"格式校验发现错误,剩余分块及约束校验未执行。"
|
|
887
|
+
),
|
|
888
|
+
}
|
|
889
|
+
)
|
|
890
|
+
chunk_interrupted = True
|
|
891
|
+
# §1.31: 中断块行数计入进度(该块已完整处理,只是不再继续后续块),
|
|
892
|
+
# 并补发中断点进度事件
|
|
893
|
+
rows_done_so_far += len(chunk_df)
|
|
894
|
+
self._emit_progress(
|
|
895
|
+
progress_callback,
|
|
896
|
+
started,
|
|
897
|
+
stage="validating",
|
|
898
|
+
table=table_id,
|
|
899
|
+
chunk_index=chunk_idx + 1,
|
|
900
|
+
chunk_total=total_chunks,
|
|
901
|
+
rows_done=rows_done_so_far,
|
|
902
|
+
rows_total=total_rows,
|
|
903
|
+
errors_so_far=len(all_errors),
|
|
904
|
+
)
|
|
905
|
+
break
|
|
906
|
+
|
|
907
|
+
except Exception as e:
|
|
908
|
+
logger.exception(f"分块校验异常: 表 {table_id} 分块 {chunk_idx}: {e}")
|
|
909
|
+
all_errors.append(
|
|
910
|
+
{
|
|
911
|
+
"error_type": "ChunkValidationError",
|
|
912
|
+
"stage": "constraint",
|
|
913
|
+
"message": f"分块校验异常: {e}",
|
|
914
|
+
"table": table_id,
|
|
915
|
+
"chunk_index": chunk_idx,
|
|
916
|
+
}
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# 累计当前分块行数(无论成功失败都已"处理"过)
|
|
920
|
+
rows_done_so_far += len(chunk_df)
|
|
921
|
+
|
|
922
|
+
# 进度:当前分块处理完成(更新 rows_done / errors_so_far)
|
|
923
|
+
self._emit_progress(
|
|
924
|
+
progress_callback,
|
|
925
|
+
started,
|
|
926
|
+
stage="validating",
|
|
927
|
+
table=table_id,
|
|
928
|
+
chunk_index=chunk_idx + 1,
|
|
929
|
+
chunk_total=total_chunks,
|
|
930
|
+
rows_done=rows_done_so_far,
|
|
931
|
+
rows_total=total_rows,
|
|
932
|
+
errors_so_far=len(all_errors),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
return all_parsed_datasets, all_errors, all_validation_details, chunk_interrupted
|
|
936
|
+
|
|
937
|
+
def _apply_chunked_global_validation(
|
|
938
|
+
self,
|
|
939
|
+
all_parsed_datasets: dict[str, list[pd.DataFrame]],
|
|
940
|
+
all_errors: list[dict],
|
|
941
|
+
all_validation_details: dict[str, list[dict]],
|
|
942
|
+
result: dict[str, Any],
|
|
943
|
+
options: ValidationOptions,
|
|
944
|
+
allow_unsafe_eval: bool,
|
|
945
|
+
deadline: float | None,
|
|
946
|
+
stop_on_first_error: bool,
|
|
947
|
+
chunk_interrupted: bool,
|
|
948
|
+
) -> None:
|
|
949
|
+
"""
|
|
950
|
+
@methoddesc 合并分块解析结果并执行全量 DAG 与约束校验(就地写入 result)
|
|
951
|
+
|
|
952
|
+
【设计意图】分块仅用于"加载与格式解析省内存";Transform DAG 与约束校验
|
|
953
|
+
都需要全局数据,因此在所有块解析完成后 concat 为全量 parsed,再统一执行。
|
|
954
|
+
行变 transform 在分块下逐块执行会导致结果与全量不一致,故 DAG 移到
|
|
955
|
+
concat 后统一执行;必须在约束校验之前,因约束可能依赖 transform 产生的
|
|
956
|
+
派生列。全量约束校验修复跨表 ForeignKey(不再缺目标表)与跨块 Unique
|
|
957
|
+
(整列去重)的正确性。
|
|
958
|
+
|
|
959
|
+
参数:
|
|
960
|
+
all_parsed_datasets/all_errors/all_validation_details: _parse_chunks_loop 的聚合输出
|
|
961
|
+
result: 校验结果字典(就地写入 parsed_datasets/errors/validation_details/interrupted)
|
|
962
|
+
options: 校验选项
|
|
963
|
+
allow_unsafe_eval: 脚本执行权限
|
|
964
|
+
deadline: 校验阶段超时截止时间
|
|
965
|
+
stop_on_first_error: C6 遇错即停
|
|
966
|
+
chunk_interrupted: 分块阶段是否已因 stop 中断(中断则跳过约束校验)
|
|
967
|
+
"""
|
|
968
|
+
merged_parsed: dict[str, pd.DataFrame] = {}
|
|
969
|
+
for table_id, dfs in all_parsed_datasets.items():
|
|
970
|
+
if dfs:
|
|
971
|
+
merged_parsed[table_id] = pd.concat(dfs, ignore_index=True)
|
|
972
|
+
|
|
973
|
+
merged_parsed, dag_errors = execute_dag_if_needed(
|
|
974
|
+
merged_parsed,
|
|
975
|
+
transform_files=getattr(self.loaded_project, "transform_files", None) if self.loaded_project else None,
|
|
976
|
+
regex_files=getattr(self.loaded_project, "regex_node_files", None) if self.loaded_project else None,
|
|
977
|
+
)
|
|
978
|
+
result["parsed_datasets"] = merged_parsed
|
|
979
|
+
# 回归 #6: 分块路径的 DAG 失败同样需上报(与标准路径 engine 行为一致)。
|
|
980
|
+
# 必须并入 all_errors——下方 result["errors"] = all_errors 整体重绑会覆盖
|
|
981
|
+
# 直接 append 进 result["errors"] 的内容,DAG 错误须在重绑前进入 all_errors。
|
|
982
|
+
for dag_err in dag_errors:
|
|
983
|
+
all_errors.append({"stage": "loading", **dag_err})
|
|
984
|
+
|
|
985
|
+
# C6: 若分块阶段已因 stop 中断,跳过约束校验(已停在前面的格式错误)。
|
|
986
|
+
if merged_parsed and not chunk_interrupted:
|
|
987
|
+
constraint_errors, constraint_details = validate_constraints(
|
|
988
|
+
merged_parsed,
|
|
989
|
+
self.dataset_schema,
|
|
990
|
+
all_errors=[], # 约束阶段独立计错误,避免与分块格式错误混排
|
|
991
|
+
validation_details={
|
|
992
|
+
"format_checks": [], # 占位,下方合并时只取 constraint_checks
|
|
993
|
+
"constraint_checks": [],
|
|
994
|
+
},
|
|
995
|
+
allow_unsafe_eval=allow_unsafe_eval,
|
|
996
|
+
table_filter=options.table_filter,
|
|
997
|
+
deadline=deadline,
|
|
998
|
+
stop_on_first_error=stop_on_first_error,
|
|
999
|
+
)
|
|
1000
|
+
all_errors.extend(constraint_errors)
|
|
1001
|
+
# 用全量约束结果替换分块阶段(分块循环 skip 了约束,constraint_checks 原本为空)
|
|
1002
|
+
all_validation_details["constraint_checks"] = constraint_details.get("constraint_checks", [])
|
|
1003
|
+
|
|
1004
|
+
result["errors"] = all_errors
|
|
1005
|
+
result["validation_details"] = all_validation_details
|
|
1006
|
+
# C6: 分块阶段因 stop 中断,或约束阶段因 stop 中断,均标记 interrupted
|
|
1007
|
+
if chunk_interrupted or (
|
|
1008
|
+
stop_on_first_error and any(e.get("error_type") == "ValidationInterrupted" for e in all_errors)
|
|
1009
|
+
):
|
|
1010
|
+
result["interrupted"] = True
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def create_executor(manifest_path: str, settings_override: dict[str, Any] | None = None) -> ValidationExecutor:
|
|
1014
|
+
"""
|
|
1015
|
+
@methoddesc 创建校验执行器工厂函数
|
|
1016
|
+
|
|
1017
|
+
便捷函数,用于快速创建 ValidationExecutor 实例。
|
|
1018
|
+
|
|
1019
|
+
参数:
|
|
1020
|
+
manifest_path: 项目清单文件路径
|
|
1021
|
+
settings_override: 可选的设置覆盖值
|
|
1022
|
+
|
|
1023
|
+
返回:
|
|
1024
|
+
ValidationExecutor 实例
|
|
1025
|
+
|
|
1026
|
+
示例:
|
|
1027
|
+
>>> executor = create_executor("project.precis.yaml")
|
|
1028
|
+
>>> result = executor.execute("./data")
|
|
1029
|
+
"""
|
|
1030
|
+
return ValidationExecutor(manifest_path, settings_override)
|