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,467 @@
|
|
|
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
|
+
"""@fileoverview 飞书消息推送报告器
|
|
17
|
+
|
|
18
|
+
功能概述:
|
|
19
|
+
- 通过飞书 Webhook/App/Service 三种模式发送错误报告
|
|
20
|
+
- 支持 Interactive Card 消息卡片和自动签名验证
|
|
21
|
+
- 三种模式按安全性优先级自动选择: Service > App > Webhook
|
|
22
|
+
|
|
23
|
+
架构设计:
|
|
24
|
+
- 继承 Reporter 抽象基类,实现 configure() 和 report() 方法
|
|
25
|
+
- 多模式策略: configure() 根据提供的参数自动选择最佳模式
|
|
26
|
+
- Token 管理: App 模式下 _get_tenant_access_token() 实现缓存 + 自动刷新
|
|
27
|
+
- 签名验证: Webhook 模式下 _generate_sign() 实现 HMAC-SHA256 签名
|
|
28
|
+
- 消息标准化: _create_message_card() 统一生成飞书卡片消息结构
|
|
29
|
+
- 通用请求: _send_request() 封装所有 HTTP 通信
|
|
30
|
+
|
|
31
|
+
输入示例:
|
|
32
|
+
Service 模式配置 (推荐):
|
|
33
|
+
{"service_url": "https://my-service.com/feishu", "api_key": "xxx"}
|
|
34
|
+
|
|
35
|
+
App 模式配置:
|
|
36
|
+
{"app_id": "cli_xxx", "app_secret": "xxx", "receive_ids": "ou_xxx", "receive_id_type": "open_id"}
|
|
37
|
+
|
|
38
|
+
Webhook 模式配置:
|
|
39
|
+
{"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx", "secret": "xxx"}
|
|
40
|
+
|
|
41
|
+
输出示例:
|
|
42
|
+
飞书卡片消息:
|
|
43
|
+
{
|
|
44
|
+
"config": {"wide_screen_mode": True},
|
|
45
|
+
"header": {"template": "red", "title": {"content": "【数据验证警告】发现 3 个错误!", "tag": "plain_text"}},
|
|
46
|
+
"elements": [...]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
控制台输出:
|
|
50
|
+
[FeishuReporter] ✓ 配置成功 (模式: 自建应用)。
|
|
51
|
+
[FeishuReporter] ✓ 成功获取 Tenant Access Token。
|
|
52
|
+
[FeishuReporter] ✓ 成功发送消息 给 ou_xxx。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import base64
|
|
56
|
+
import hashlib
|
|
57
|
+
import hmac
|
|
58
|
+
import ipaddress
|
|
59
|
+
import json
|
|
60
|
+
import logging
|
|
61
|
+
import time
|
|
62
|
+
import urllib.request
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from typing import Any
|
|
65
|
+
from urllib.parse import urlparse
|
|
66
|
+
|
|
67
|
+
from .base import TEMPLATE_HEADROOM_BYTES, Reporter, sanitize_url_for_log, truncate_to_byte_limit
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
_ALLOWED_URL_SCHEMES = {"https", "http"}
|
|
72
|
+
_BLOCKED_NETWORKS = [
|
|
73
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
74
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
75
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
76
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
77
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
78
|
+
ipaddress.ip_network("::1/128"),
|
|
79
|
+
ipaddress.ip_network("fc00::/7"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate_webhook_url(url: str) -> None:
|
|
84
|
+
parsed = urlparse(url)
|
|
85
|
+
if parsed.scheme not in _ALLOWED_URL_SCHEMES:
|
|
86
|
+
raise ValueError(f"不支持的 URL scheme: {parsed.scheme},仅允许 {_ALLOWED_URL_SCHEMES}")
|
|
87
|
+
hostname = parsed.hostname
|
|
88
|
+
if not hostname:
|
|
89
|
+
raise ValueError("URL 缺少主机名")
|
|
90
|
+
try:
|
|
91
|
+
for network in _BLOCKED_NETWORKS:
|
|
92
|
+
if ipaddress.ip_address(hostname) in network:
|
|
93
|
+
raise ValueError(f"不允许访问内网地址: {hostname}")
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
# B40 修复:过去用 str(ValueError) 取类本身的字符串(恒为 "<class 'ValueError'>"),
|
|
96
|
+
# 导致内网地址拦截的 raise 永不执行,SSRF 校验形同虚设。应为 str(e) 取异常消息。
|
|
97
|
+
if "不允许访问内网地址" in str(e):
|
|
98
|
+
raise
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FeishuReporter(Reporter):
|
|
104
|
+
"""
|
|
105
|
+
@classdesc 飞书消息推送报告器
|
|
106
|
+
|
|
107
|
+
通过飞书发送数据验证错误报告,支持三种发送模式:
|
|
108
|
+
1. Webhook: 单用户快速集成,零部署
|
|
109
|
+
2. App: 团队使用,需要分发凭证
|
|
110
|
+
3. Service: 团队使用,通过中心化服务统一管理,最安全
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
config: 存储配置参数的字典
|
|
114
|
+
mode: 当前使用的发送模式 ('webhook', 'app', 'service')
|
|
115
|
+
is_configured: 配置是否有效的标志
|
|
116
|
+
tenant_access_token: 飞书应用访问令牌(App 模式专用)
|
|
117
|
+
token_expires_at: 令牌过期时间戳(App 模式专用)
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
@methoddesc 初始化飞书报告器
|
|
123
|
+
|
|
124
|
+
设置默认属性,包括配置字典、发送模式、配置状态标志,
|
|
125
|
+
以及 App 模式专用的访问令牌和过期时间戳。
|
|
126
|
+
"""
|
|
127
|
+
super().__init__("FeishuReporter")
|
|
128
|
+
self.config: dict[str, Any] = {} # 存储配置参数
|
|
129
|
+
self.mode: str | None = None # 当前发送模式: 'webhook' | 'app' | 'service'
|
|
130
|
+
self.is_configured = False # 配置是否有效
|
|
131
|
+
|
|
132
|
+
# App 模式专用属性
|
|
133
|
+
self.tenant_access_token = None # 飞书应用访问令牌
|
|
134
|
+
self.token_expires_at = 0 # 令牌过期时间戳
|
|
135
|
+
|
|
136
|
+
def configure(self, **config: Any) -> bool:
|
|
137
|
+
"""
|
|
138
|
+
@methoddesc 根据配置参数自动检测并设置最佳操作模式
|
|
139
|
+
|
|
140
|
+
配置优先级(按安全性排序):
|
|
141
|
+
1. Service 模式:需要 service_url 和 api_key
|
|
142
|
+
2. App 模式:需要 app_id, app_secret 和 receive_ids
|
|
143
|
+
3. Webhook 模式:只需要 webhook_url
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
**config: 包含飞书配置的键值对
|
|
147
|
+
- service_url: 中心化服务地址
|
|
148
|
+
- api_key: API 密钥
|
|
149
|
+
- app_id: 应用 ID
|
|
150
|
+
- app_secret: 应用密钥
|
|
151
|
+
- receive_ids: 接收者 ID 列表
|
|
152
|
+
- receive_id_type: 接收者 ID 类型
|
|
153
|
+
- webhook_url: Webhook 地址
|
|
154
|
+
- secret: 签名密钥
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
bool: 配置是否成功
|
|
158
|
+
"""
|
|
159
|
+
self.config = config
|
|
160
|
+
|
|
161
|
+
# 模式三:中心化服务模式(最高优先级,最安全)
|
|
162
|
+
# 适用于团队统一管理所有消息通知
|
|
163
|
+
if config.get("service_url") and config.get("api_key"):
|
|
164
|
+
self.mode = "service"
|
|
165
|
+
self.is_configured = True
|
|
166
|
+
print(f"[{self.name}] ✓ 配置成功 (模式: 中心化服务)。")
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
# 模式二:自建应用模式
|
|
170
|
+
# 适用于需要发送给多个用户且有飞书应用的团队
|
|
171
|
+
if config.get("app_id") and config.get("app_secret") and config.get("receive_ids"):
|
|
172
|
+
self.mode = "app"
|
|
173
|
+
self.is_configured = True
|
|
174
|
+
print(f"[{self.name}] ✓ 配置成功 (模式: 自建应用)。")
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
# 模式一:Webhook 模式(最低优先级)
|
|
178
|
+
# 适用于快速集成和单用户场景
|
|
179
|
+
if config.get("webhook_url"):
|
|
180
|
+
self.mode = "webhook"
|
|
181
|
+
self.is_configured = True
|
|
182
|
+
print(f"[{self.name}] ✓ 配置成功 (模式: Webhook)。")
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
# 配置不完整时输出错误提示
|
|
186
|
+
print(
|
|
187
|
+
f"[{self.name}] !! 错误: 配置不完整。请提供 'service_url'/'api_key' 或 'app_id'/'app_secret' 或 'webhook_url'。"
|
|
188
|
+
)
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
def report(self, errors: list[dict]) -> None:
|
|
192
|
+
"""
|
|
193
|
+
@methoddesc 发送错误报告的主入口方法
|
|
194
|
+
|
|
195
|
+
根据当前配置的模式调用相应的发送方法。
|
|
196
|
+
如果未配置,则跳过发送并输出提示。
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
errors: 错误信息列表,每个元素是一个包含错误详情的字典
|
|
200
|
+
"""
|
|
201
|
+
# 前置检查:确保已正确配置
|
|
202
|
+
if not self.is_configured:
|
|
203
|
+
print(f"[{self.name}] 未配置,跳过报告。")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# 步骤1: 统一创建消息卡片内容
|
|
207
|
+
# 将错误列表转换为飞书消息卡片格式
|
|
208
|
+
card_content = self._create_message_card(errors)
|
|
209
|
+
|
|
210
|
+
# 步骤2: 根据模式分发到不同的发送方法
|
|
211
|
+
if self.mode == "service":
|
|
212
|
+
self._report_via_service(card_content)
|
|
213
|
+
elif self.mode == "app":
|
|
214
|
+
self._report_via_app(card_content)
|
|
215
|
+
elif self.mode == "webhook":
|
|
216
|
+
self._report_via_webhook(card_content)
|
|
217
|
+
|
|
218
|
+
def _create_message_card(self, errors: list[dict]) -> dict:
|
|
219
|
+
"""
|
|
220
|
+
@methoddesc 统一生成飞书 Interactive Card 消息卡片结构体
|
|
221
|
+
|
|
222
|
+
消息卡片包含:
|
|
223
|
+
- 红色标题栏显示错误数量
|
|
224
|
+
- 错误详情的 JSON 格式化展示
|
|
225
|
+
- 报告时间戳
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
errors: 错误信息列表
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Dict: 飞书消息卡片的 JSON 结构
|
|
232
|
+
"""
|
|
233
|
+
# 统计错误数量
|
|
234
|
+
error_count = len(errors)
|
|
235
|
+
# 将错误列表序列化为格式化的 JSON 字符串
|
|
236
|
+
error_details = json.dumps(errors, indent=2, ensure_ascii=False)
|
|
237
|
+
|
|
238
|
+
# 安全截断:飞书消息卡片大小限制约为 30KB
|
|
239
|
+
# 这里做保守截断(28KB)。必须按 UTF-8 字节截断:平台限额按字节计,
|
|
240
|
+
# 中文按字符截断后实际字节数仍可能超限被拒收(见 truncate_to_byte_limit)
|
|
241
|
+
error_details = truncate_to_byte_limit(error_details, 28000 - TEMPLATE_HEADROOM_BYTES)
|
|
242
|
+
|
|
243
|
+
# 构建飞书卡片消息结构
|
|
244
|
+
return {
|
|
245
|
+
"config": {"wide_screen_mode": True}, # 宽屏模式
|
|
246
|
+
"header": {
|
|
247
|
+
"template": "red", # 红色标题表示警告
|
|
248
|
+
"title": {"content": f"【数据验证警告】发现 {error_count} 个错误!", "tag": "plain_text"},
|
|
249
|
+
},
|
|
250
|
+
"elements": [
|
|
251
|
+
# 错误详情区域:使用 Markdown 代码块格式
|
|
252
|
+
{"tag": "div", "text": {"content": f"**错误详情:**\n```{error_details}```", "tag": "lark_md"}},
|
|
253
|
+
{"tag": "hr"}, # 分割线
|
|
254
|
+
# 底部备注:显示报告时间
|
|
255
|
+
{
|
|
256
|
+
"tag": "note",
|
|
257
|
+
"elements": [
|
|
258
|
+
{"content": f"报告时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", "tag": "lark_md"}
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# ==============================================================================
|
|
265
|
+
# 模式一:Webhook 实现
|
|
266
|
+
# ==============================================================================
|
|
267
|
+
def _report_via_webhook(self, card_content: dict) -> None:
|
|
268
|
+
"""
|
|
269
|
+
@methoddesc 通过 Webhook 发送消息(Webhook 模式)
|
|
270
|
+
|
|
271
|
+
适用于单用户快速集成场景。Webhook 是开放接口,
|
|
272
|
+
通过签名验证请求合法性。
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
card_content: 飞书消息卡片内容字典
|
|
276
|
+
"""
|
|
277
|
+
# 构建消息负载:包含卡片类型和内容
|
|
278
|
+
payload: dict[str, Any] = {"msg_type": "interactive", "card": card_content}
|
|
279
|
+
|
|
280
|
+
# 可选:添加签名验证(如果配置了 secret)
|
|
281
|
+
secret = self.config.get("secret")
|
|
282
|
+
if secret:
|
|
283
|
+
# 生成签名:timestamp + secret -> HMAC-SHA256 -> Base64
|
|
284
|
+
timestamp = int(time.time())
|
|
285
|
+
sign = self._generate_sign(secret, timestamp)
|
|
286
|
+
payload["timestamp"] = timestamp
|
|
287
|
+
payload["sign"] = sign
|
|
288
|
+
|
|
289
|
+
# 发送 HTTP POST 请求
|
|
290
|
+
self._send_request(
|
|
291
|
+
url=self.config["webhook_url"], payload=payload, headers={"Content-Type": "application/json"}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _generate_sign(self, secret: str, timestamp: int) -> str:
|
|
295
|
+
"""
|
|
296
|
+
@methoddesc 生成飞书 Webhook 签名
|
|
297
|
+
|
|
298
|
+
签名算法:
|
|
299
|
+
1. 拼接 timestamp 和 secret 成字符串
|
|
300
|
+
2. 使用 HMAC-SHA256 算法加密
|
|
301
|
+
3. Base64 编码结果
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
secret: Webhook 签名密钥
|
|
305
|
+
timestamp: 时间戳(秒)
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
str: 签名字符串
|
|
309
|
+
"""
|
|
310
|
+
# 构造待签名字符串:timestamp + 换行 + secret
|
|
311
|
+
string_to_sign = f"{timestamp}\n{secret}"
|
|
312
|
+
# HMAC-SHA256 加密
|
|
313
|
+
hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest()
|
|
314
|
+
# Base64 编码
|
|
315
|
+
return base64.b64encode(hmac_code).decode("utf-8")
|
|
316
|
+
|
|
317
|
+
# ==============================================================================
|
|
318
|
+
# 模式二:自建应用实现
|
|
319
|
+
# ==============================================================================
|
|
320
|
+
def _report_via_app(self, card_content: dict) -> None:
|
|
321
|
+
"""
|
|
322
|
+
@methoddesc 通过飞书自建应用发送消息(App 模式)
|
|
323
|
+
|
|
324
|
+
适用于团队场景,需要预先创建飞书自建应用并配置权限。
|
|
325
|
+
消息通过飞书开放平台 API 发送。
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
card_content: 飞书消息卡片内容字典
|
|
329
|
+
"""
|
|
330
|
+
# 步骤1: 获取有效的访问令牌
|
|
331
|
+
token = self._get_tenant_access_token()
|
|
332
|
+
if not token:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
# 步骤2: 解析接收者列表
|
|
336
|
+
# 支持逗号分隔的多个接收者
|
|
337
|
+
receive_id_list = self.config["receive_ids"].split(",")
|
|
338
|
+
receive_id_type = self.config.get("receive_id_type", "open_id")
|
|
339
|
+
|
|
340
|
+
# 步骤3: 构建飞书 IM API 请求
|
|
341
|
+
url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}"
|
|
342
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"}
|
|
343
|
+
|
|
344
|
+
# 步骤4: 逐个向接收者发送消息
|
|
345
|
+
for receive_id in receive_id_list:
|
|
346
|
+
# 构建消息负载
|
|
347
|
+
# 注意:content 字段需要 JSON 序列化为字符串
|
|
348
|
+
payload = {"receive_id": receive_id.strip(), "msg_type": "interactive", "content": json.dumps(card_content)}
|
|
349
|
+
self._send_request(url, payload, headers, target_id=receive_id.strip())
|
|
350
|
+
|
|
351
|
+
def _get_tenant_access_token(self) -> str | None:
|
|
352
|
+
"""
|
|
353
|
+
@methoddesc 获取飞书应用访问令牌(Tenant Access Token)
|
|
354
|
+
|
|
355
|
+
令牌用于调用飞书开放平台 API,有效期约 2 小时。
|
|
356
|
+
实现自动刷新机制:在过期前 5 分钟自动刷新。
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
str: 有效的访问令牌,如果获取失败返回 None
|
|
360
|
+
|
|
361
|
+
Token 刷新逻辑:
|
|
362
|
+
- 检查当前时间是否早于过期时间
|
|
363
|
+
- 如果是,直接返回缓存的令牌
|
|
364
|
+
- 否则调用 API 获取新令牌并更新缓存
|
|
365
|
+
"""
|
|
366
|
+
# 检查令牌是否仍然有效(提前 5 分钟刷新)
|
|
367
|
+
if time.time() < self.token_expires_at:
|
|
368
|
+
return self.tenant_access_token
|
|
369
|
+
|
|
370
|
+
# 调用飞书 OAuth API 获取令牌
|
|
371
|
+
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
|
372
|
+
payload = {"app_id": self.config["app_id"], "app_secret": self.config["app_secret"]}
|
|
373
|
+
try:
|
|
374
|
+
# 构建 HTTP 请求
|
|
375
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
376
|
+
req = urllib.request.Request(url, data=req_data, headers={"Content-Type": "application/json"})
|
|
377
|
+
|
|
378
|
+
# 发送请求并解析响应
|
|
379
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
380
|
+
result = json.loads(response.read().decode())
|
|
381
|
+
if result.get("code") == 0:
|
|
382
|
+
# 成功获取令牌
|
|
383
|
+
self.tenant_access_token = result["tenant_access_token"]
|
|
384
|
+
# 设置过期时间:当前时间 + 有效期 - 缓冲时间
|
|
385
|
+
self.token_expires_at = time.time() + result["expire"] - 300
|
|
386
|
+
print(f"[{self.name}] ✓ 成功获取 Tenant Access Token。")
|
|
387
|
+
return self.tenant_access_token
|
|
388
|
+
else:
|
|
389
|
+
print(f"[{self.name}] !! 错误: 获取 Tenant Access Token 失败: {result.get('msg')}")
|
|
390
|
+
return None
|
|
391
|
+
except Exception as e:
|
|
392
|
+
print(f"[{self.name}] !! 错误: 请求 Tenant Access Token 失败: {e}")
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
# ==============================================================================
|
|
396
|
+
# 模式三:中心化服务实现
|
|
397
|
+
# ==============================================================================
|
|
398
|
+
def _report_via_service(self, card_content: dict) -> None:
|
|
399
|
+
"""
|
|
400
|
+
@methoddesc 通过中心化服务发送消息(Service 模式)
|
|
401
|
+
|
|
402
|
+
适用于团队统一管理消息通知的场景。
|
|
403
|
+
所有消息通过自建的中转服务发送,安全性最高。
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
card_content: 飞书消息卡片内容字典
|
|
407
|
+
"""
|
|
408
|
+
# 构建请求头:包含 API 密钥认证
|
|
409
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.config['api_key']}"}
|
|
410
|
+
|
|
411
|
+
# 构建请求负载:将卡片内容序列化为 JSON 字符串
|
|
412
|
+
# 服务端负责解析和发送飞书消息
|
|
413
|
+
payload = {"message": json.dumps(card_content)}
|
|
414
|
+
|
|
415
|
+
# 发送请求到中心化服务
|
|
416
|
+
self._send_request(self.config["service_url"], payload, headers)
|
|
417
|
+
|
|
418
|
+
# ==============================================================================
|
|
419
|
+
# 通用请求发送器
|
|
420
|
+
# ==============================================================================
|
|
421
|
+
def _send_request(self, url: str, payload: dict, headers: dict, target_id: str = "N/A") -> None:
|
|
422
|
+
"""
|
|
423
|
+
@methoddesc 通用 HTTP POST 请求发送器
|
|
424
|
+
|
|
425
|
+
处理所有飞书 API 的 HTTP 通信,包括错误处理和日志记录。
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
url: 请求目标 URL
|
|
429
|
+
payload: 请求体数据字典
|
|
430
|
+
headers: HTTP 请求头字典
|
|
431
|
+
target_id: 目标接收者 ID(用于日志记录)
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
_validate_webhook_url(url)
|
|
435
|
+
except ValueError as e:
|
|
436
|
+
print(f"[{self.name}] !! 错误: URL 校验失败: {e}")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
# 序列化请求负载为 JSON 字节
|
|
441
|
+
data = json.dumps(payload).encode("utf-8")
|
|
442
|
+
|
|
443
|
+
# 构建 HTTP 请求
|
|
444
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
445
|
+
|
|
446
|
+
# 发送请求并获取响应
|
|
447
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
448
|
+
result_str = response.read().decode("utf-8")
|
|
449
|
+
result = json.loads(result_str)
|
|
450
|
+
|
|
451
|
+
# 判断不同 API 的成功响应码
|
|
452
|
+
# 飞书 Webhook/App API: code == 0
|
|
453
|
+
# 通用 HTTP: status < 300
|
|
454
|
+
if result.get("code") == 0 or result.get("status_code") == 0 or response.status < 300:
|
|
455
|
+
target_info = f"给 {target_id}" if target_id != "N/A" else ""
|
|
456
|
+
print(f"[{self.name}] ✓ 成功发送消息 {target_info}。")
|
|
457
|
+
else:
|
|
458
|
+
# 提取错误信息(不同 API 字段名不同)
|
|
459
|
+
print(f"[{self.name}] !! 错误: 发送消息失败: {result.get('msg', result_str)}")
|
|
460
|
+
except Exception as e:
|
|
461
|
+
# webhook_url 的 token 在 query 中,日志只打 host+path,避免密钥落日志
|
|
462
|
+
logger.warning(
|
|
463
|
+
"[%s] !! 错误: 发送请求到 '%s' 失败: %s",
|
|
464
|
+
self.name,
|
|
465
|
+
sanitize_url_for_log(url),
|
|
466
|
+
e,
|
|
467
|
+
)
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
"""@fileoverview 本地文件报告器模块
|
|
17
|
+
|
|
18
|
+
功能概述:
|
|
19
|
+
- 将数据验证错误报告以 JSON 格式写入本地文件系统
|
|
20
|
+
- 支持自动创建日志目录和时间戳命名文件
|
|
21
|
+
- 每个报告生成独立的文件,避免覆盖历史记录
|
|
22
|
+
|
|
23
|
+
架构设计:
|
|
24
|
+
- 继承 Reporter 抽象基类,实现 configure() 和 report() 方法
|
|
25
|
+
- 目录管理: configure() 自动创建不存在的日志目录
|
|
26
|
+
- 时间戳命名: 使用 datetime.now().strftime("%Y%m%d_%H%M%S_%f") 毫秒级时间戳生成唯一文件名,避免同秒覆盖
|
|
27
|
+
- UTF-8 编码: 确保中文字符正确存储
|
|
28
|
+
- 格式化输出: indent=2 使 JSON 文件便于人工阅读
|
|
29
|
+
|
|
30
|
+
输入示例:
|
|
31
|
+
配置参数:
|
|
32
|
+
{"log_dir": "./logs"}
|
|
33
|
+
|
|
34
|
+
错误数据:
|
|
35
|
+
[
|
|
36
|
+
{"error_type": "TypeValidationError", "row_index": 2, "column": "price", "value": "abc"},
|
|
37
|
+
{"error_type": "NotNullViolation", "row_index": 5, "column": "email"}
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
输出示例:
|
|
41
|
+
生成的日志文件: ./logs/error_report_20240115_143052.json
|
|
42
|
+
|
|
43
|
+
文件内容:
|
|
44
|
+
[
|
|
45
|
+
{
|
|
46
|
+
"error_type": "TypeValidationError",
|
|
47
|
+
"row_index": 2,
|
|
48
|
+
"column": "price",
|
|
49
|
+
"value": "abc"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"error_type": "NotNullViolation",
|
|
53
|
+
"row_index": 5,
|
|
54
|
+
"column": "email"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
控制台输出:
|
|
59
|
+
[LocalFileReporter] 日志目录已创建: ./logs
|
|
60
|
+
[LocalFileReporter] ✓ 成功将错误报告写入本地日志: ./logs/error_report_20240115_143052.json
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
from datetime import datetime
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
from .base import Reporter
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LocalFileReporter(Reporter):
|
|
72
|
+
"""
|
|
73
|
+
@classdesc 本地文件报告器 - 将错误报告写入本地文件系统
|
|
74
|
+
|
|
75
|
+
该报告器将数据验证错误以 JSON 格式写入本地日志目录。
|
|
76
|
+
适用于需要本地持久化错误日志的场景,或作为邮件报告的补充方案。
|
|
77
|
+
|
|
78
|
+
属性说明:
|
|
79
|
+
log_dir: 日志文件存放的目录路径
|
|
80
|
+
可以是相对路径或绝对路径
|
|
81
|
+
|
|
82
|
+
功能特点:
|
|
83
|
+
- 自动创建日志目录(如果不存在)
|
|
84
|
+
- 每个报告生成独立的时间戳文件名,避免覆盖
|
|
85
|
+
- JSON 格式存储,便于程序化处理
|
|
86
|
+
- UTF-8 编码,支持中文错误信息
|
|
87
|
+
|
|
88
|
+
使用示例:
|
|
89
|
+
reporter = LocalFileReporter()
|
|
90
|
+
if reporter.configure(log_dir='./logs/errors'):
|
|
91
|
+
reporter.report(errors)
|
|
92
|
+
# 将在 ./logs/errors/ 目录生成 error_report_20240215_143052.json
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
"""
|
|
97
|
+
初始化本地文件报告器
|
|
98
|
+
|
|
99
|
+
调用父类构造函数设置报告器名称为 'LocalFileReporter'。
|
|
100
|
+
初始化日志目录为 None,需要通过 configure() 方法设置。
|
|
101
|
+
"""
|
|
102
|
+
super().__init__("LocalFileReporter")
|
|
103
|
+
self.log_dir: str | None = None
|
|
104
|
+
|
|
105
|
+
def configure(self, **config: Any) -> bool:
|
|
106
|
+
"""
|
|
107
|
+
@methoddesc 配置日志目录并确保目录存在
|
|
108
|
+
|
|
109
|
+
该方法设置错误报告文件的存储目录。
|
|
110
|
+
如果目录不存在,会尝试自动创建。
|
|
111
|
+
|
|
112
|
+
:param log_dir: 日志文件存放的目录路径
|
|
113
|
+
可以是相对路径(如 './logs')或绝对路径
|
|
114
|
+
:param config: 额外的可选配置参数(当前未使用,保留扩展性)
|
|
115
|
+
:return: 配置是否成功。成功返回 True,失败返回 False
|
|
116
|
+
|
|
117
|
+
配置流程:
|
|
118
|
+
1. 将日志目录路径保存到实例变量
|
|
119
|
+
2. 检查目录是否存在
|
|
120
|
+
3. 如不存在,尝试创建目录
|
|
121
|
+
4. 返回配置结果
|
|
122
|
+
"""
|
|
123
|
+
# 保存日志目录路径到实例变量
|
|
124
|
+
log_dir = config.get("log_dir")
|
|
125
|
+
if not isinstance(log_dir, str) or not log_dir:
|
|
126
|
+
print(f"[{self.name}] !! 错误: 未提供 log_dir 配置。")
|
|
127
|
+
return False
|
|
128
|
+
self.log_dir = log_dir
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# 检查目录是否存在,os.path.exists() 既检查文件也检查目录
|
|
132
|
+
if not os.path.exists(self.log_dir):
|
|
133
|
+
# 目录不存在,尝试创建(包括父目录)
|
|
134
|
+
os.makedirs(self.log_dir)
|
|
135
|
+
print(f"[{self.name}] 日志目录已创建: {self.log_dir}")
|
|
136
|
+
|
|
137
|
+
# 配置成功
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
# 创建目录失败,打印错误信息
|
|
142
|
+
print(f"[{self.name}] !! 错误: 创建日志目录失败: {e}")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
def report(self, errors: list[dict]) -> None:
|
|
146
|
+
"""
|
|
147
|
+
@methoddesc 将错误报告写入本地 JSON 文件
|
|
148
|
+
|
|
149
|
+
在写入前会检查日志目录是否已配置。
|
|
150
|
+
文件名包含精确到秒的时间戳,确保每次报告都有唯一的文件。
|
|
151
|
+
|
|
152
|
+
:param errors: 错误信息的字典列表,每个字典包含错误详情
|
|
153
|
+
典型字段:row_index, column, value, error_type, error_message
|
|
154
|
+
|
|
155
|
+
写入流程:
|
|
156
|
+
1. 检查日志目录是否已配置
|
|
157
|
+
2. 生成包含时间戳的唯一文件名
|
|
158
|
+
3. 构造完整的文件路径
|
|
159
|
+
4. 使用 json.dump() 将错误列表写入文件
|
|
160
|
+
5. 打印成功或失败消息
|
|
161
|
+
|
|
162
|
+
文件格式示例:
|
|
163
|
+
[
|
|
164
|
+
{
|
|
165
|
+
"row_index": 2,
|
|
166
|
+
"column": "price",
|
|
167
|
+
"value": "abc",
|
|
168
|
+
"error_type": "TypeValidationError",
|
|
169
|
+
"error_message": "无法将 'abc' 转换为浮点数"
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
"""
|
|
173
|
+
# 前置检查:确保日志目录已配置
|
|
174
|
+
if not self.log_dir:
|
|
175
|
+
print(f"[{self.name}] !! 错误: 未配置日志目录,无法写入日志。")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# 生成时间戳,用于构建唯一的文件名
|
|
179
|
+
# 格式:YYYYMMDD_HHMMSS_mmm,例如 20240215_143052_123。
|
|
180
|
+
# 毫秒粒度避免同一秒内的多次报告互相覆盖(同秒覆盖丢报告);
|
|
181
|
+
# 极端情况下(同一毫秒内多次写入)追加序号兜底,保证文件名唯一。
|
|
182
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
|
183
|
+
|
|
184
|
+
# 构建日志文件名;已存在时追加序号去重
|
|
185
|
+
log_filename = f"error_report_{timestamp}.json"
|
|
186
|
+
sequence = 1
|
|
187
|
+
while os.path.exists(os.path.join(self.log_dir, log_filename)):
|
|
188
|
+
sequence += 1
|
|
189
|
+
log_filename = f"error_report_{timestamp}_{sequence}.json"
|
|
190
|
+
|
|
191
|
+
# 拼接完整的文件路径
|
|
192
|
+
log_filepath = os.path.join(self.log_dir, log_filename)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
# 打开文件并写入 JSON 数据
|
|
196
|
+
# 使用 utf-8 编码确保中文字符正确保存
|
|
197
|
+
with open(log_filepath, "w", encoding="utf-8") as f:
|
|
198
|
+
# 使用 json.dump 序列化错误列表
|
|
199
|
+
# indent=2 使输出的 JSON 格式化,便于人工阅读
|
|
200
|
+
# ensure_ascii=False 允许直接写入 Unicode 字符(如中文)
|
|
201
|
+
json.dump(errors, f, indent=2, ensure_ascii=False)
|
|
202
|
+
|
|
203
|
+
# 写入成功,打印确认信息
|
|
204
|
+
print(f"[{self.name}] ✓ 成功将错误报告写入本地日志: {log_filepath}")
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
# 写入失败,打印错误信息
|
|
208
|
+
print(f"[{self.name}] !! 错误: 写入日志文件失败: {e}")
|