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,313 @@
|
|
|
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
|
+
"""
|
|
17
|
+
@fileoverview SQL 数据源加载器模块
|
|
18
|
+
|
|
19
|
+
功能概述:
|
|
20
|
+
- 通过 SQLAlchemy 连接数据库并执行 SELECT / WITH 查询
|
|
21
|
+
- 使用 pandas.read_sql 加载结果为 DataFrame
|
|
22
|
+
- 查询字符串安全清洗:拒绝非 SELECT/WITH、分号、危险关键字(B20)
|
|
23
|
+
- 支持 schema-prefixed 表名(如 public.users,B21)
|
|
24
|
+
- 模块级引擎缓存,按 connection_string 复用,避免连接池耗尽(B22)
|
|
25
|
+
|
|
26
|
+
架构设计:
|
|
27
|
+
- 继承 DataSourceLoader,通过注册表自动发现
|
|
28
|
+
- _get_engine() 管理 SQLAlchemy 引擎生命周期(带缓存)
|
|
29
|
+
- _sanitize_query() 在查询执行前进行白名单校验
|
|
30
|
+
- 危险关键字和语法特征通过模块级常量集中管理
|
|
31
|
+
|
|
32
|
+
输入示例:
|
|
33
|
+
spec = SQLSourceSpec(
|
|
34
|
+
connection_string="postgresql://user:pass@localhost/db",
|
|
35
|
+
query="SELECT id, name FROM public.users WHERE active = true"
|
|
36
|
+
)
|
|
37
|
+
loader = SQLLoader(spec)
|
|
38
|
+
|
|
39
|
+
输出示例:
|
|
40
|
+
df = loader.load()
|
|
41
|
+
# 返回 pandas.DataFrame,仅包含查询结果
|
|
42
|
+
# 若查询包含危险关键字或非 SELECT 语句则抛出 DataLoadError
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import logging
|
|
48
|
+
import re
|
|
49
|
+
import threading
|
|
50
|
+
from collections import OrderedDict
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import pandas as pd
|
|
54
|
+
|
|
55
|
+
from ..specs.sql_source import SQLSourceSpec
|
|
56
|
+
from .base import DataLoadError, DataSourceLoader
|
|
57
|
+
from .registry import register_loader
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
# 模块级引擎缓存,按 connection_string 复用,避免连接池耗尽(B22)。
|
|
62
|
+
# B-locksafety: 多线程安全改造——
|
|
63
|
+
# 1. 校验执行器会把同步加载工作 offload 到线程池(executor.py),_get_engine 会被
|
|
64
|
+
# 多线程并发调用,原裸 dict 无锁存在竞态(两线程同时 miss 各建一个 engine,
|
|
65
|
+
# 一个成孤儿永不 dispose)。
|
|
66
|
+
# 2. 缓存改为 OrderedDict 实现 LRU(容量 _ENGINE_CACHE_MAX),淘汰时调用
|
|
67
|
+
# engine.dispose() 释放底层连接池,避免轮换凭据/多 DB 场景 FD 耗尽。
|
|
68
|
+
# 3. atexit 注册进程退出时全量 dispose(桌面应用生命周期短,主要靠 LRU 淘汰兜底)。
|
|
69
|
+
_ENGINE_CACHE_MAX = 8
|
|
70
|
+
_engine_cache: OrderedDict[str, Any] = OrderedDict()
|
|
71
|
+
_engine_cache_lock = threading.Lock()
|
|
72
|
+
|
|
73
|
+
# 危险的 SQL 关键字,用于拒绝潜在的注入攻击(B20)。
|
|
74
|
+
# 词级关键字按 \b 词边界匹配(列名 last_update 不再误杀);符号类按子串匹配。
|
|
75
|
+
_WORD_BOUNDARY_SQL_KEYWORDS = (
|
|
76
|
+
"drop",
|
|
77
|
+
"delete",
|
|
78
|
+
"insert",
|
|
79
|
+
"update",
|
|
80
|
+
"create",
|
|
81
|
+
"alter",
|
|
82
|
+
"truncate",
|
|
83
|
+
"grant",
|
|
84
|
+
"revoke",
|
|
85
|
+
"exec",
|
|
86
|
+
"execute",
|
|
87
|
+
"union",
|
|
88
|
+
)
|
|
89
|
+
_SUBSTRING_SQL_KEYWORDS = (
|
|
90
|
+
"--",
|
|
91
|
+
"/*",
|
|
92
|
+
"*/",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@register_loader("sql")
|
|
97
|
+
class SQLLoader(DataSourceLoader["SQLSourceSpec"]):
|
|
98
|
+
"""
|
|
99
|
+
@classdesc SQL 数据库加载器
|
|
100
|
+
|
|
101
|
+
使用 pandas 的 read_sql 方法从数据库加载数据。
|
|
102
|
+
支持多种数据库后端(通过 SQLAlchemy)。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
spec_class = SQLSourceSpec # 类型由泛型指定
|
|
106
|
+
|
|
107
|
+
def _get_engine(self, connection_string: str) -> Any:
|
|
108
|
+
"""
|
|
109
|
+
@methoddesc 获取或创建数据库引擎(带缓存)。
|
|
110
|
+
|
|
111
|
+
使用模块级字典 _engine_cache 按 connection_string 缓存引擎实例,
|
|
112
|
+
避免重复创建连接池导致资源耗尽。
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
connection_string: SQLAlchemy 数据库连接字符串
|
|
116
|
+
例如 "postgresql://user:pass@localhost/db"
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
SQLAlchemy 引擎实例
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ImportError: 如果未安装 sqlalchemy 则会在 import 时抛出
|
|
123
|
+
|
|
124
|
+
示例:
|
|
125
|
+
>>> engine = loader._get_engine("sqlite:///test.db")
|
|
126
|
+
"""
|
|
127
|
+
from sqlalchemy import create_engine
|
|
128
|
+
|
|
129
|
+
# B-locksafety: 加锁保证"检查-创建-放入缓存"原子,避免并发 miss 各建 engine。
|
|
130
|
+
# 锁只保护 dict 操作(快),create_engine 在锁外执行后会再次拿锁写入——
|
|
131
|
+
# 但为简单起见这里在锁内创建(SQLLoader 加载本身是 offload 到线程的批操作,
|
|
132
|
+
# create_engine 的开销相对 DB 查询可忽略,优先保证正确性)。
|
|
133
|
+
with _engine_cache_lock:
|
|
134
|
+
if connection_string in _engine_cache:
|
|
135
|
+
# 命中:移到队尾标记为最近使用(LRU)
|
|
136
|
+
_engine_cache.move_to_end(connection_string)
|
|
137
|
+
return _engine_cache[connection_string]
|
|
138
|
+
|
|
139
|
+
engine = create_engine(connection_string)
|
|
140
|
+
|
|
141
|
+
# 放入缓存前,若已达容量上限,淘汰最久未用的 engine 并 dispose 其连接池
|
|
142
|
+
while len(_engine_cache) >= _ENGINE_CACHE_MAX:
|
|
143
|
+
_, evicted = _engine_cache.popitem(last=False)
|
|
144
|
+
try:
|
|
145
|
+
evicted.dispose()
|
|
146
|
+
except Exception as e: # noqa: BLE001
|
|
147
|
+
logger.warning("淘汰 SQL engine 时 dispose 失败(可能已关闭): %s", e)
|
|
148
|
+
|
|
149
|
+
_engine_cache[connection_string] = engine
|
|
150
|
+
return engine
|
|
151
|
+
|
|
152
|
+
def _sanitize_query(self, query: str) -> str:
|
|
153
|
+
"""
|
|
154
|
+
@methoddesc 检查查询字符串是否包含危险的 SQL 注入特征(B20)。
|
|
155
|
+
|
|
156
|
+
安全策略(白名单模式):
|
|
157
|
+
1. 只允许以 SELECT 或 WITH 开头的查询
|
|
158
|
+
2. 拒绝包含分号(;)的查询,防止多语句执行
|
|
159
|
+
3. 拒绝危险关键字(如 DROP、DELETE、INSERT、UPDATE 等)
|
|
160
|
+
|
|
161
|
+
如果查询不通过安全检查,会抛出 DataLoadError。
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
query: 用户提供的原始 SQL 查询字符串
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
清洗后的查询字符串(去除首尾空白)
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
DataLoadError: 查询包含危险特征或不以 SELECT/WITH 开头时抛出
|
|
171
|
+
|
|
172
|
+
示例:
|
|
173
|
+
>>> safe_query = loader._sanitize_query("SELECT * FROM users")
|
|
174
|
+
>>> # 返回: "SELECT * FROM users"
|
|
175
|
+
>>> loader._sanitize_query("DROP TABLE users")
|
|
176
|
+
>>> # 抛出 DataLoadError
|
|
177
|
+
"""
|
|
178
|
+
stripped = query.strip()
|
|
179
|
+
lower = stripped.lower()
|
|
180
|
+
|
|
181
|
+
# 只允许 SELECT 和 WITH 开头的查询
|
|
182
|
+
if not lower.startswith(("select", "with")):
|
|
183
|
+
raise DataLoadError(
|
|
184
|
+
f"SQL 查询必须以 SELECT 或 WITH 开头,当前查询: {stripped[:80]}...",
|
|
185
|
+
self.spec,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# 拒绝分号(多语句特征)
|
|
189
|
+
if ";" in stripped:
|
|
190
|
+
raise DataLoadError(
|
|
191
|
+
"SQL 查询不允许包含分号(;),请使用单条 SELECT 语句",
|
|
192
|
+
self.spec,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 拒绝危险关键字。
|
|
196
|
+
# 词级关键字(drop/update 等)按词边界匹配:子串匹配会把列名 last_update
|
|
197
|
+
# (含 "update ")误判为危险语句。_ 是词字符,\b 不在其前,故 last_update 放行、
|
|
198
|
+
# 独立的 UPDATE 仍拒绝。符号类(--、/*、*/)无词边界概念,保持子串匹配。
|
|
199
|
+
for keyword in _WORD_BOUNDARY_SQL_KEYWORDS:
|
|
200
|
+
if re.search(rf"\b{keyword}\b", lower):
|
|
201
|
+
raise DataLoadError(
|
|
202
|
+
f"SQL 查询包含危险关键字 '{keyword}',已被拒绝",
|
|
203
|
+
self.spec,
|
|
204
|
+
)
|
|
205
|
+
for symbol in _SUBSTRING_SQL_KEYWORDS:
|
|
206
|
+
if symbol in lower:
|
|
207
|
+
raise DataLoadError(
|
|
208
|
+
f"SQL 查询包含危险特征 '{symbol}',已被拒绝",
|
|
209
|
+
self.spec,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return stripped
|
|
213
|
+
|
|
214
|
+
def load(self) -> pd.DataFrame:
|
|
215
|
+
"""
|
|
216
|
+
@methoddesc 从 SQL 数据库加载数据
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
DataFrame
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
DataLoadError: 加载失败时抛出
|
|
223
|
+
ImportError: 缺少必要的驱动时抛出
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
from sqlalchemy import text
|
|
227
|
+
except ImportError:
|
|
228
|
+
raise DataLoadError("加载 SQL 数据源需要安装 sqlalchemy: pip install sqlalchemy", self.spec)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# 复用已缓存的引擎(B22)
|
|
232
|
+
engine = self._get_engine(self.spec.connection_string)
|
|
233
|
+
|
|
234
|
+
# 构建查询
|
|
235
|
+
query_input = self.spec.table_or_query.strip()
|
|
236
|
+
if query_input.lower().startswith(("select", "with")):
|
|
237
|
+
# 用户提供了查询语句,先进行安全检查(B20)
|
|
238
|
+
safe_query = self._sanitize_query(query_input)
|
|
239
|
+
with engine.connect() as conn:
|
|
240
|
+
df = pd.read_sql(text(safe_query), conn, params=self.spec.params)
|
|
241
|
+
else:
|
|
242
|
+
# 用户提供了表名,使用 SQLAlchemy 的 Table + select 安全构建查询
|
|
243
|
+
# Table 名称会被自动引用,防止 SQL 注入
|
|
244
|
+
from sqlalchemy import MetaData, Table, select
|
|
245
|
+
|
|
246
|
+
# 表名中禁止出现可疑字符
|
|
247
|
+
if not re.match(r"^[\w.]+$", query_input):
|
|
248
|
+
raise DataLoadError(f"表名包含非法字符: {query_input}", self.spec)
|
|
249
|
+
|
|
250
|
+
# 支持 schema.table 格式(如 public.users)(B21)
|
|
251
|
+
if "." in query_input:
|
|
252
|
+
schema_name, table_name = query_input.rsplit(".", 1)
|
|
253
|
+
table = Table(table_name, MetaData(), schema=schema_name)
|
|
254
|
+
else:
|
|
255
|
+
table = Table(query_input, MetaData())
|
|
256
|
+
with engine.connect() as conn:
|
|
257
|
+
df = pd.read_sql(select(table), conn)
|
|
258
|
+
|
|
259
|
+
return df
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
raise DataLoadError(f"SQL 加载失败: {e}", self.spec, e)
|
|
263
|
+
|
|
264
|
+
def validate(self) -> list[str]:
|
|
265
|
+
"""
|
|
266
|
+
@methoddesc 验证 SQL 数据源配置。
|
|
267
|
+
|
|
268
|
+
检查项:
|
|
269
|
+
- 连接字符串是否为空
|
|
270
|
+
- 表名或查询语句是否为空
|
|
271
|
+
- SQLAlchemy 库是否已安装
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
错误信息列表,空列表表示验证通过
|
|
275
|
+
|
|
276
|
+
示例:
|
|
277
|
+
>>> errors = loader.validate()
|
|
278
|
+
>>> if errors:
|
|
279
|
+
... print("验证失败:", errors)
|
|
280
|
+
"""
|
|
281
|
+
errors = []
|
|
282
|
+
|
|
283
|
+
# 检查连接字符串
|
|
284
|
+
if not self.spec.connection_string:
|
|
285
|
+
errors.append("连接字符串不能为空")
|
|
286
|
+
|
|
287
|
+
# 检查表名/查询
|
|
288
|
+
if not self.spec.table_or_query:
|
|
289
|
+
errors.append("表名或查询语句不能为空")
|
|
290
|
+
|
|
291
|
+
# 检查 SQLAlchemy 是否可用
|
|
292
|
+
import importlib.util
|
|
293
|
+
|
|
294
|
+
if importlib.util.find_spec("sqlalchemy") is None:
|
|
295
|
+
errors.append("缺少 SQLAlchemy: pip install sqlalchemy")
|
|
296
|
+
|
|
297
|
+
return errors
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _dispose_all_engines() -> None:
|
|
301
|
+
"""进程退出时全量释放缓存的 SQL engine 连接池(B-locksafety)。"""
|
|
302
|
+
with _engine_cache_lock:
|
|
303
|
+
while _engine_cache:
|
|
304
|
+
_, engine = _engine_cache.popitem(last=False)
|
|
305
|
+
try:
|
|
306
|
+
engine.dispose()
|
|
307
|
+
except Exception as e: # noqa: BLE001
|
|
308
|
+
logger.warning("进程退出 dispose SQL engine 失败: %s", e)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
import atexit
|
|
312
|
+
|
|
313
|
+
atexit.register(_dispose_all_engines)
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
"""
|
|
17
|
+
@fileoverview JSON 解析策略模块入口
|
|
18
|
+
|
|
19
|
+
功能概述:
|
|
20
|
+
- 聚合和导出所有 JSON 解析策略类
|
|
21
|
+
- 提供 get_parser 工厂函数按格式获取解析器
|
|
22
|
+
- 支持 array、lines、object 三种解析格式(D8: auto 已废弃)
|
|
23
|
+
- 统一管理解析策略的公共接口
|
|
24
|
+
|
|
25
|
+
架构设计:
|
|
26
|
+
- 策略模式:各解析器实现 JSONParserStrategy 协议
|
|
27
|
+
- 工厂模式:get_parser 按格式字符串返回对应解析器实例
|
|
28
|
+
- 与 JSONLoader 配合使用,实现 JSON 的显式格式解析
|
|
29
|
+
|
|
30
|
+
输入示例:
|
|
31
|
+
>>> from app.shared.core.data_source.loaders.strategies import get_parser
|
|
32
|
+
>>> parser = get_parser("array") # 顶层数组格式
|
|
33
|
+
>>> parser = get_parser("lines") # JSON Lines 格式
|
|
34
|
+
>>> parser = get_parser("object") # 嵌套对象格式
|
|
35
|
+
|
|
36
|
+
输出示例:
|
|
37
|
+
>>> parser = get_parser("array")
|
|
38
|
+
>>> data = parser.parse('[{"id": 1}, {"id": 2}]')
|
|
39
|
+
>>> # 返回 [{"id": 1}, {"id": 2}]
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from .array_parser import ArrayParser
|
|
45
|
+
from .lines_parser import (
|
|
46
|
+
JSONParserStrategy,
|
|
47
|
+
LinesParser,
|
|
48
|
+
ParserStrategyRegistry,
|
|
49
|
+
StandardJSONParser,
|
|
50
|
+
)
|
|
51
|
+
from .object_parser import ObjectParser
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"JSONParserStrategy",
|
|
55
|
+
"LinesParser",
|
|
56
|
+
"StandardJSONParser",
|
|
57
|
+
"ParserStrategyRegistry",
|
|
58
|
+
"ArrayParser",
|
|
59
|
+
"ObjectParser",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_parser(format: str) -> JSONParserStrategy:
|
|
64
|
+
"""
|
|
65
|
+
@methoddesc 获取指定格式的解析器
|
|
66
|
+
|
|
67
|
+
D8: format 必须显式指定(array/lines/object),不再支持 auto 自动检测。
|
|
68
|
+
auto 会猜测主数据数组(取最长/取第一个),与精确列定义的设计矛盾,已废弃。
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
format: JSON 格式类型(必填)
|
|
72
|
+
- "array": 顶层数组 JSON(如 [{...}])
|
|
73
|
+
- "lines": JSON Lines 格式(每行一个 JSON)
|
|
74
|
+
- "object": 嵌套对象格式(需配合 json_path 精确指定数据数组)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
JSON 解析器实例
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: format="auto"(已废弃)或不支持时抛出,引导用户改用显式 format
|
|
81
|
+
"""
|
|
82
|
+
# D8: auto 已废弃,明确报错引导(不替用户猜该改成什么)
|
|
83
|
+
if format == "auto":
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"format='auto' 已废弃(自动检测会猜测主数据数组,可能导致校验跑错对象)。"
|
|
86
|
+
"请显式指定 format:array(顶层数组 [{...}])、lines(JSON Lines)、"
|
|
87
|
+
"或 object(嵌套对象,需配合 json_path 如 $.data 精确指定数据数组路径)。"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
parsers: dict[str, type[JSONParserStrategy]] = {
|
|
91
|
+
"array": ArrayParser,
|
|
92
|
+
"lines": LinesParser,
|
|
93
|
+
"object": ObjectParser,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
parser_class = parsers.get(format)
|
|
97
|
+
if not parser_class:
|
|
98
|
+
raise ValueError(f"不支持的 JSON 格式: {format},支持的格式: {list(parsers.keys())}(auto 已废弃)")
|
|
99
|
+
|
|
100
|
+
return parser_class()
|
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
"""
|
|
17
|
+
@fileoverview ArrayParser JSON 数组解析策略
|
|
18
|
+
|
|
19
|
+
功能概述:
|
|
20
|
+
- 解析标准 JSON 数组格式: [{"a": 1}, {"a": 2}]
|
|
21
|
+
- 解析单个对象包装格式: {"a": 1}
|
|
22
|
+
- 自动检测输入数据是否为数组格式
|
|
23
|
+
- 处理 json.JSONDecodeError 异常
|
|
24
|
+
|
|
25
|
+
架构设计:
|
|
26
|
+
- 实现 JSONParserStrategy Protocol 协议
|
|
27
|
+
- 策略模式用于支持多种 JSON 解析方式
|
|
28
|
+
- 可与 JSONLoader 配合使用
|
|
29
|
+
|
|
30
|
+
输入示例:
|
|
31
|
+
# 标准数组格式
|
|
32
|
+
'[{"a": 1}, {"a": 2}]'
|
|
33
|
+
# 单个对象格式
|
|
34
|
+
'{"a": 1}'
|
|
35
|
+
# 嵌套数组格式
|
|
36
|
+
'{"data": [{"a": 1}, {"a": 2}]}'
|
|
37
|
+
|
|
38
|
+
输出示例:
|
|
39
|
+
[{"a": 1}, {"a": 2}] # 返回字典列表
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
from typing import Any, Protocol, runtime_checkable
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class JSONParserStrategy(Protocol):
|
|
53
|
+
"""
|
|
54
|
+
@classdesc JSON 解析策略协议
|
|
55
|
+
|
|
56
|
+
定义 JSON 解析器的标准接口。
|
|
57
|
+
实现此协议的具体策略类可以处理不同格式的 JSON 数据。
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def can_parse(self, raw_content: str) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
@methoddesc 检测是否能够解析给定的原始内容
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
raw_content: 原始 JSON 字符串
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
如果能够解析返回 True,否则返回 False
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def parse(self, raw_content: str) -> list[dict[str, Any]]:
|
|
73
|
+
"""
|
|
74
|
+
@methoddesc 解析 JSON 字符串为字典列表
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
raw_content: 原始 JSON 字符串
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
字典列表
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ArrayParseError: 解析失败时抛出
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ArrayParseError(Exception):
|
|
89
|
+
"""
|
|
90
|
+
@classdesc JSON 数组解析异常
|
|
91
|
+
|
|
92
|
+
当 JSON 数组解析失败时抛出此异常,
|
|
93
|
+
携带原始错误信息以便排查问题。
|
|
94
|
+
|
|
95
|
+
使用场景:
|
|
96
|
+
- JSON 格式非法导致解析失败
|
|
97
|
+
- 输入数据类型不匹配(期望数组/字典但收到其他类型)
|
|
98
|
+
- JSONPath 指向的路径不存在
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, message: str, cause: Exception | None = None):
|
|
102
|
+
"""
|
|
103
|
+
@methoddesc 初始化解析异常
|
|
104
|
+
|
|
105
|
+
参数:
|
|
106
|
+
message: 错误描述信息
|
|
107
|
+
cause: 原始异常对象(可选,用于异常链追踪)
|
|
108
|
+
"""
|
|
109
|
+
super().__init__(message)
|
|
110
|
+
self.cause = cause
|
|
111
|
+
|
|
112
|
+
def __str__(self) -> str:
|
|
113
|
+
msg = super().__str__()
|
|
114
|
+
if self.cause:
|
|
115
|
+
msg = f"{msg} (原因: {self.cause})"
|
|
116
|
+
return msg
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ArrayParser:
|
|
120
|
+
"""
|
|
121
|
+
@classdesc JSON 数组解析器
|
|
122
|
+
|
|
123
|
+
专门处理 JSON 数组格式的解析器,支持:
|
|
124
|
+
- 标准 JSON 数组格式: [{"a": 1}, {"a": 2}]
|
|
125
|
+
- 单个对象包装格式: {"a": 1}
|
|
126
|
+
- 嵌套数组提取: {"data": [{"a": 1}, {"a": 2}]}
|
|
127
|
+
|
|
128
|
+
使用示例:
|
|
129
|
+
parser = ArrayParser()
|
|
130
|
+
|
|
131
|
+
# 解析标准数组
|
|
132
|
+
result = parser.parse('[{"a": 1}, {"a": 2}]')
|
|
133
|
+
# 返回: [{"a": 1}, {"a": 2}]
|
|
134
|
+
|
|
135
|
+
# 解析单个对象
|
|
136
|
+
result = parser.parse('{"a": 1}')
|
|
137
|
+
# 返回: [{"a": 1}]
|
|
138
|
+
|
|
139
|
+
# 检测是否可解析
|
|
140
|
+
can_parse = parser.can_parse('[{"a": 1}]')
|
|
141
|
+
# 返回: True
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def can_parse(self, raw_content: str) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
@methoddesc 检测给定的原始内容是否为数组格式
|
|
147
|
+
|
|
148
|
+
检测逻辑:
|
|
149
|
+
1. 首先尝试 JSON 解析,检查解析结果是否为列表
|
|
150
|
+
2. 如果是字典,检查是否包含列表类型的值
|
|
151
|
+
3. 处理空白字符和常见格式错误
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
raw_content: 原始 JSON 字符串
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
如果可以解析为数组格式返回 True,否则返回 False
|
|
158
|
+
"""
|
|
159
|
+
if not raw_content or not isinstance(raw_content, str):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
stripped = raw_content.strip()
|
|
163
|
+
if not stripped:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
parsed = json.loads(stripped)
|
|
168
|
+
|
|
169
|
+
# 直接是列表
|
|
170
|
+
if isinstance(parsed, list):
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
# D8: 收紧 array 格式。仅接受"无内嵌数组的 dict"(单条记录,如 {"a":1})。
|
|
174
|
+
# 含内嵌数组的嵌套对象(如 {"data":[...]})不算 array 格式——主数据数组位置
|
|
175
|
+
# 有歧义,必须用 format=object + json_path 精确指定,不再猜测取第一个数组。
|
|
176
|
+
if isinstance(parsed, dict):
|
|
177
|
+
has_nested_list = any(isinstance(value, list) for value in parsed.values())
|
|
178
|
+
return not has_nested_list
|
|
179
|
+
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
183
|
+
logger.debug(f"JSON 格式检测失败: {e}")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
def parse(self, raw_content: str) -> list[dict[str, Any]]:
|
|
187
|
+
"""
|
|
188
|
+
@methoddesc 解析 JSON 字符串为字典列表
|
|
189
|
+
|
|
190
|
+
支持的格式:
|
|
191
|
+
1. 标准数组: [{"a": 1}, {"a": 2}] -> 返回原列表
|
|
192
|
+
2. 单个对象: {"a": 1} -> 返回包装后的列表 [{"a": 1}]
|
|
193
|
+
3. 嵌套对象: {"data": [{"a": 1}]} -> 提取内层数组
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
raw_content: 原始 JSON 字符串
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
字典列表
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ArrayParseError: JSON 解析失败或格式不支持时抛出
|
|
203
|
+
"""
|
|
204
|
+
if not raw_content or not isinstance(raw_content, str):
|
|
205
|
+
raise ArrayParseError("输入为空或类型错误")
|
|
206
|
+
|
|
207
|
+
stripped = raw_content.strip()
|
|
208
|
+
if not stripped:
|
|
209
|
+
raise ArrayParseError("输入内容为空")
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
parsed = json.loads(stripped)
|
|
213
|
+
except json.JSONDecodeError as e:
|
|
214
|
+
raise ArrayParseError(f"JSON 解析失败 (行 {e.lineno}, 列 {e.colno}): {e.msg}", cause=e)
|
|
215
|
+
except ValueError as e:
|
|
216
|
+
raise ArrayParseError(f"JSON 格式错误: {e}", cause=e)
|
|
217
|
+
|
|
218
|
+
# 处理不同格式
|
|
219
|
+
if isinstance(parsed, list):
|
|
220
|
+
return self._parse_list(parsed)
|
|
221
|
+
|
|
222
|
+
elif isinstance(parsed, dict):
|
|
223
|
+
return self._parse_dict(parsed)
|
|
224
|
+
|
|
225
|
+
else:
|
|
226
|
+
raise ArrayParseError(f"不支持的 JSON 类型: {type(parsed).__name__},期望列表或字典")
|
|
227
|
+
|
|
228
|
+
def _parse_list(self, data: list) -> list[dict[str, Any]]:
|
|
229
|
+
"""
|
|
230
|
+
@methoddesc 解析列表数据
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
data: 解析后的列表数据
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
字典列表
|
|
237
|
+
"""
|
|
238
|
+
result: list[dict[str, Any]] = []
|
|
239
|
+
|
|
240
|
+
for item in data:
|
|
241
|
+
if isinstance(item, dict):
|
|
242
|
+
result.append(item)
|
|
243
|
+
else:
|
|
244
|
+
# 非字典类型,包装为单值对象
|
|
245
|
+
result.append({"value": item})
|
|
246
|
+
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
def _parse_dict(self, data: dict) -> list[dict[str, Any]]:
|
|
250
|
+
"""
|
|
251
|
+
@methoddesc 解析字典数据
|
|
252
|
+
|
|
253
|
+
D8 策略(零启发式):
|
|
254
|
+
- 含内嵌数组的嵌套对象(如 {"data":[...]})→ 报错,引导改用 format=object + json_path
|
|
255
|
+
精确指定数据数组路径(不再猜测取第一个数组)。
|
|
256
|
+
- 无内嵌数组的单条 dict(如 {"a":1})→ 返回 [data](无歧义)。
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
data: 解析后的字典数据
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
字典列表
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ArrayParseError: 字典含内嵌数组(嵌套对象)时抛出
|
|
266
|
+
"""
|
|
267
|
+
# D8: 含内嵌数组的嵌套对象拒绝(主数据数组位置有歧义,不应猜测)
|
|
268
|
+
has_nested_list = any(isinstance(value, list) for value in data.values())
|
|
269
|
+
if has_nested_list:
|
|
270
|
+
raise ArrayParseError(
|
|
271
|
+
"array 格式仅支持顶层数组 JSON(如 [{...}])或无内嵌数组的单条对象;"
|
|
272
|
+
"嵌套对象(含内嵌数组)请改用 format=object 并通过 json_path "
|
|
273
|
+
"(如 $.data)精确指定数据数组路径。"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# 无内嵌数组,将字典作为单条记录
|
|
277
|
+
return [data]
|