alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
alysis_code/updates.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from importlib import metadata as importlib_metadata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from packaging.version import InvalidVersion, Version
|
|
20
|
+
|
|
21
|
+
from .atomic_io import atomic_write_json
|
|
22
|
+
from .branding import (
|
|
23
|
+
LEGACY_PYTHON_PACKAGE_NAME,
|
|
24
|
+
PYTHON_PACKAGE_NAME,
|
|
25
|
+
canonical_user_data_dir,
|
|
26
|
+
env_get,
|
|
27
|
+
)
|
|
28
|
+
from .config import AppConfig, ConfigError
|
|
29
|
+
from .safety import SafeHttpError, safe_http_request
|
|
30
|
+
|
|
31
|
+
PYPI_JSON_URL = f"https://pypi.org/pypi/{PYTHON_PACKAGE_NAME}/json"
|
|
32
|
+
UPDATE_CACHE_SCHEMA_VERSION = 1
|
|
33
|
+
UPDATE_PROMPT_STATE_SCHEMA_VERSION = 1
|
|
34
|
+
DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 24
|
|
35
|
+
DEFAULT_UPDATE_CHECK_TIMEOUT_S = 3.0
|
|
36
|
+
DEFAULT_UPDATE_PROMPT_SNOOZE_HOURS = 24
|
|
37
|
+
UPDATE_CACHE_MAX_BYTES = 512 * 1024
|
|
38
|
+
_update_refresh_lock = threading.Lock()
|
|
39
|
+
_update_refresh_started = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UpdateCheckError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class UpdateCacheRecord:
|
|
48
|
+
checked_at: datetime
|
|
49
|
+
package: str
|
|
50
|
+
source: str
|
|
51
|
+
latest_version: str | None = None
|
|
52
|
+
url: str | None = None
|
|
53
|
+
error: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return bool(self.latest_version) and not self.error
|
|
58
|
+
|
|
59
|
+
def to_json(self) -> dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"schema_version": UPDATE_CACHE_SCHEMA_VERSION,
|
|
62
|
+
"checked_at": self.checked_at.astimezone(UTC).isoformat(),
|
|
63
|
+
"package": self.package,
|
|
64
|
+
"source": self.source,
|
|
65
|
+
"latest_version": self.latest_version,
|
|
66
|
+
"url": self.url,
|
|
67
|
+
"error": self.error,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class UpdateStatus:
|
|
73
|
+
current_version: str
|
|
74
|
+
latest_version: str | None
|
|
75
|
+
checked_at: datetime | None
|
|
76
|
+
source: str
|
|
77
|
+
url: str | None = None
|
|
78
|
+
error: str | None = None
|
|
79
|
+
from_cache: bool = False
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def update_available(self) -> bool:
|
|
83
|
+
if not self.latest_version:
|
|
84
|
+
return False
|
|
85
|
+
return _version_is_newer(self.latest_version, self.current_version)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def up_to_date(self) -> bool:
|
|
89
|
+
if not self.latest_version or self.error:
|
|
90
|
+
return False
|
|
91
|
+
return not self.update_available
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def state(self) -> str:
|
|
95
|
+
if self.error:
|
|
96
|
+
return "error"
|
|
97
|
+
if self.update_available:
|
|
98
|
+
return "update_available"
|
|
99
|
+
if self.up_to_date:
|
|
100
|
+
return "current"
|
|
101
|
+
return "unknown"
|
|
102
|
+
|
|
103
|
+
def to_json(self) -> dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"current_version": self.current_version,
|
|
106
|
+
"latest_version": self.latest_version,
|
|
107
|
+
"checked_at": self.checked_at.astimezone(UTC).isoformat() if self.checked_at else None,
|
|
108
|
+
"source": self.source,
|
|
109
|
+
"url": self.url,
|
|
110
|
+
"error": self.error,
|
|
111
|
+
"from_cache": self.from_cache,
|
|
112
|
+
"state": self.state,
|
|
113
|
+
"update_available": self.update_available,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class UpdatePromptState:
|
|
119
|
+
"""Per-user memory of the startup update prompt.
|
|
120
|
+
|
|
121
|
+
Lives next to ``update_check.json`` in the data dir (its own file, so the
|
|
122
|
+
background cache refresh can never wipe it).
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
skipped_version: str | None = None
|
|
126
|
+
last_prompted_version: str | None = None
|
|
127
|
+
last_prompted_at: datetime | None = None
|
|
128
|
+
|
|
129
|
+
def to_json(self) -> dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"schema_version": UPDATE_PROMPT_STATE_SCHEMA_VERSION,
|
|
132
|
+
"package": PYTHON_PACKAGE_NAME,
|
|
133
|
+
"skipped_version": self.skipped_version,
|
|
134
|
+
"last_prompted_version": self.last_prompted_version,
|
|
135
|
+
"last_prompted_at": (
|
|
136
|
+
self.last_prompted_at.astimezone(UTC).isoformat() if self.last_prompted_at else None
|
|
137
|
+
),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class InstallerPlan:
|
|
143
|
+
method: str
|
|
144
|
+
supported: bool
|
|
145
|
+
command: tuple[str, ...] = ()
|
|
146
|
+
reason: str = ""
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def display_command(self) -> str:
|
|
150
|
+
if not self.command:
|
|
151
|
+
return ""
|
|
152
|
+
return shlex.join(self.command)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def update_cache_path() -> Path:
|
|
156
|
+
override = env_get("ALYSIS_UPDATE_CACHE_PATH")
|
|
157
|
+
if override:
|
|
158
|
+
return Path(override)
|
|
159
|
+
data_override = env_get("ALYSIS_DATA_DIR")
|
|
160
|
+
data_dir = Path(data_override) if data_override else canonical_user_data_dir()
|
|
161
|
+
return data_dir / "update_check.json"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_update_check_enabled(cfg: AppConfig | None) -> bool:
|
|
165
|
+
env_value = env_get("ALYSIS_UPDATE_CHECK_ENABLED")
|
|
166
|
+
if env_value is not None:
|
|
167
|
+
return _parse_bool(env_value, key="ALYSIS_UPDATE_CHECK_ENABLED")
|
|
168
|
+
if cfg is None:
|
|
169
|
+
return True
|
|
170
|
+
return bool(getattr(cfg, "update_check_enabled", True))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_update_check_interval_hours(cfg: AppConfig | None) -> int:
|
|
174
|
+
env_value = env_get("ALYSIS_UPDATE_CHECK_INTERVAL_HOURS")
|
|
175
|
+
if env_value is not None:
|
|
176
|
+
return _parse_positive_int(env_value, key="ALYSIS_UPDATE_CHECK_INTERVAL_HOURS")
|
|
177
|
+
if cfg is None:
|
|
178
|
+
return DEFAULT_UPDATE_CHECK_INTERVAL_HOURS
|
|
179
|
+
return _parse_positive_int(
|
|
180
|
+
getattr(cfg, "update_check_interval_hours", DEFAULT_UPDATE_CHECK_INTERVAL_HOURS),
|
|
181
|
+
key="update_check_interval_hours",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_update_check_timeout_s(cfg: AppConfig | None) -> float:
|
|
186
|
+
env_value = env_get("ALYSIS_UPDATE_CHECK_TIMEOUT_S")
|
|
187
|
+
if env_value is not None:
|
|
188
|
+
return _parse_positive_float(env_value, key="ALYSIS_UPDATE_CHECK_TIMEOUT_S")
|
|
189
|
+
if cfg is None:
|
|
190
|
+
return DEFAULT_UPDATE_CHECK_TIMEOUT_S
|
|
191
|
+
return _parse_positive_float(
|
|
192
|
+
getattr(cfg, "update_check_timeout_s", DEFAULT_UPDATE_CHECK_TIMEOUT_S),
|
|
193
|
+
key="update_check_timeout_s",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_update_cache(path: Path | None = None) -> UpdateCacheRecord | None:
|
|
198
|
+
cache_path = path or update_cache_path()
|
|
199
|
+
if not cache_path.exists():
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
if cache_path.stat().st_size > UPDATE_CACHE_MAX_BYTES:
|
|
203
|
+
return None
|
|
204
|
+
except OSError:
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
raw = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
208
|
+
except Exception:
|
|
209
|
+
return None
|
|
210
|
+
if not isinstance(raw, dict):
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
schema_version = int(raw.get("schema_version") or 0)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
return None
|
|
216
|
+
if schema_version != UPDATE_CACHE_SCHEMA_VERSION:
|
|
217
|
+
return None
|
|
218
|
+
checked_at = _parse_datetime(raw.get("checked_at"))
|
|
219
|
+
if checked_at is None:
|
|
220
|
+
return None
|
|
221
|
+
package = str(raw.get("package") or "").strip()
|
|
222
|
+
if package != PYTHON_PACKAGE_NAME:
|
|
223
|
+
return None
|
|
224
|
+
return UpdateCacheRecord(
|
|
225
|
+
checked_at=checked_at,
|
|
226
|
+
package=package,
|
|
227
|
+
source=str(raw.get("source") or "pypi").strip() or "pypi",
|
|
228
|
+
latest_version=_optional_non_empty_string(raw.get("latest_version")),
|
|
229
|
+
url=_optional_non_empty_string(raw.get("url")),
|
|
230
|
+
error=_optional_non_empty_string(raw.get("error")),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_update_cache(record: UpdateCacheRecord, path: Path | None = None) -> None:
|
|
235
|
+
atomic_write_json(path or update_cache_path(), record.to_json())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cache_is_fresh(
|
|
239
|
+
record: UpdateCacheRecord | None,
|
|
240
|
+
*,
|
|
241
|
+
cfg: AppConfig | None,
|
|
242
|
+
now: datetime | None = None,
|
|
243
|
+
) -> bool:
|
|
244
|
+
if record is None:
|
|
245
|
+
return False
|
|
246
|
+
current = _utcnow() if now is None else _ensure_utc(now)
|
|
247
|
+
age = current - record.checked_at
|
|
248
|
+
return timedelta(0) <= age < timedelta(hours=resolve_update_check_interval_hours(cfg))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def status_from_cache(
|
|
252
|
+
*,
|
|
253
|
+
current_version: str,
|
|
254
|
+
cfg: AppConfig | None,
|
|
255
|
+
now: datetime | None = None,
|
|
256
|
+
path: Path | None = None,
|
|
257
|
+
) -> UpdateStatus:
|
|
258
|
+
if not resolve_update_check_enabled(cfg):
|
|
259
|
+
return UpdateStatus(
|
|
260
|
+
current_version=current_version,
|
|
261
|
+
latest_version=None,
|
|
262
|
+
checked_at=None,
|
|
263
|
+
source="disabled",
|
|
264
|
+
error=None,
|
|
265
|
+
from_cache=True,
|
|
266
|
+
)
|
|
267
|
+
record = read_update_cache(path)
|
|
268
|
+
if record is None:
|
|
269
|
+
return UpdateStatus(
|
|
270
|
+
current_version=current_version,
|
|
271
|
+
latest_version=None,
|
|
272
|
+
checked_at=None,
|
|
273
|
+
source="cache",
|
|
274
|
+
from_cache=True,
|
|
275
|
+
)
|
|
276
|
+
stale = not cache_is_fresh(record, cfg=cfg, now=now)
|
|
277
|
+
error = record.error
|
|
278
|
+
if stale and not record.latest_version:
|
|
279
|
+
error = error or "cached update check is stale"
|
|
280
|
+
return UpdateStatus(
|
|
281
|
+
current_version=current_version,
|
|
282
|
+
latest_version=record.latest_version,
|
|
283
|
+
checked_at=record.checked_at,
|
|
284
|
+
source=record.source,
|
|
285
|
+
url=record.url,
|
|
286
|
+
error=error,
|
|
287
|
+
from_cache=True,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_for_updates(
|
|
292
|
+
*,
|
|
293
|
+
current_version: str,
|
|
294
|
+
cfg: AppConfig | None,
|
|
295
|
+
force: bool = False,
|
|
296
|
+
now: datetime | None = None,
|
|
297
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
298
|
+
cache_path: Path | None = None,
|
|
299
|
+
) -> UpdateStatus:
|
|
300
|
+
current_time = _utcnow() if now is None else _ensure_utc(now)
|
|
301
|
+
if not force:
|
|
302
|
+
cached = read_update_cache(cache_path)
|
|
303
|
+
if cache_is_fresh(cached, cfg=cfg, now=current_time):
|
|
304
|
+
assert cached is not None
|
|
305
|
+
return UpdateStatus(
|
|
306
|
+
current_version=current_version,
|
|
307
|
+
latest_version=cached.latest_version,
|
|
308
|
+
checked_at=cached.checked_at,
|
|
309
|
+
source=cached.source,
|
|
310
|
+
url=cached.url,
|
|
311
|
+
error=cached.error,
|
|
312
|
+
from_cache=True,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
latest_version, url = fetch_latest_pypi_version(
|
|
317
|
+
current_version=current_version,
|
|
318
|
+
cfg=cfg,
|
|
319
|
+
transport=transport,
|
|
320
|
+
)
|
|
321
|
+
except ConfigError:
|
|
322
|
+
raise
|
|
323
|
+
except Exception as exc: # noqa: BLE001 - explicit update check should preserve readable errors
|
|
324
|
+
message = str(exc).strip() or exc.__class__.__name__
|
|
325
|
+
record = UpdateCacheRecord(
|
|
326
|
+
checked_at=current_time,
|
|
327
|
+
package=PYTHON_PACKAGE_NAME,
|
|
328
|
+
source="pypi",
|
|
329
|
+
error=message,
|
|
330
|
+
)
|
|
331
|
+
write_update_cache(record, cache_path)
|
|
332
|
+
return UpdateStatus(
|
|
333
|
+
current_version=current_version,
|
|
334
|
+
latest_version=None,
|
|
335
|
+
checked_at=current_time,
|
|
336
|
+
source="pypi",
|
|
337
|
+
error=message,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
record = UpdateCacheRecord(
|
|
341
|
+
checked_at=current_time,
|
|
342
|
+
package=PYTHON_PACKAGE_NAME,
|
|
343
|
+
source="pypi",
|
|
344
|
+
latest_version=latest_version,
|
|
345
|
+
url=url,
|
|
346
|
+
)
|
|
347
|
+
write_update_cache(record, cache_path)
|
|
348
|
+
return UpdateStatus(
|
|
349
|
+
current_version=current_version,
|
|
350
|
+
latest_version=latest_version,
|
|
351
|
+
checked_at=current_time,
|
|
352
|
+
source="pypi",
|
|
353
|
+
url=url,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def maybe_refresh_update_cache_in_background(
|
|
358
|
+
*,
|
|
359
|
+
current_version: str,
|
|
360
|
+
cfg: AppConfig | None,
|
|
361
|
+
now: datetime | None = None,
|
|
362
|
+
path: Path | None = None,
|
|
363
|
+
thread_factory: Callable[..., Any] | None = None,
|
|
364
|
+
) -> bool:
|
|
365
|
+
if not resolve_update_check_enabled(cfg):
|
|
366
|
+
return False
|
|
367
|
+
current_time = _utcnow() if now is None else _ensure_utc(now)
|
|
368
|
+
cached = read_update_cache(path)
|
|
369
|
+
if cache_is_fresh(cached, cfg=cfg, now=current_time):
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
global _update_refresh_started
|
|
373
|
+
with _update_refresh_lock:
|
|
374
|
+
if _update_refresh_started:
|
|
375
|
+
return False
|
|
376
|
+
_update_refresh_started = True
|
|
377
|
+
|
|
378
|
+
def _refresh() -> None:
|
|
379
|
+
global _update_refresh_started
|
|
380
|
+
try:
|
|
381
|
+
check_for_updates(
|
|
382
|
+
current_version=current_version,
|
|
383
|
+
cfg=cfg,
|
|
384
|
+
force=True,
|
|
385
|
+
now=current_time,
|
|
386
|
+
cache_path=path,
|
|
387
|
+
)
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
finally:
|
|
391
|
+
with _update_refresh_lock:
|
|
392
|
+
_update_refresh_started = False
|
|
393
|
+
|
|
394
|
+
factory = thread_factory or threading.Thread
|
|
395
|
+
try:
|
|
396
|
+
thread = factory(
|
|
397
|
+
target=_refresh,
|
|
398
|
+
name="alysis-update-check",
|
|
399
|
+
daemon=True,
|
|
400
|
+
)
|
|
401
|
+
thread.start()
|
|
402
|
+
except Exception:
|
|
403
|
+
with _update_refresh_lock:
|
|
404
|
+
_update_refresh_started = False
|
|
405
|
+
return False
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def fetch_latest_pypi_version(
|
|
410
|
+
*,
|
|
411
|
+
current_version: str,
|
|
412
|
+
cfg: AppConfig | None,
|
|
413
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
414
|
+
) -> tuple[str, str | None]:
|
|
415
|
+
timeout_s = resolve_update_check_timeout_s(cfg)
|
|
416
|
+
headers = {
|
|
417
|
+
"Accept": "application/json",
|
|
418
|
+
"User-Agent": f"{PYTHON_PACKAGE_NAME}/{current_version}",
|
|
419
|
+
}
|
|
420
|
+
try:
|
|
421
|
+
response = asyncio.run(
|
|
422
|
+
safe_http_request(
|
|
423
|
+
"GET",
|
|
424
|
+
PYPI_JSON_URL,
|
|
425
|
+
timeout=timeout_s,
|
|
426
|
+
max_bytes=UPDATE_CACHE_MAX_BYTES,
|
|
427
|
+
headers=headers,
|
|
428
|
+
_transport=transport,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
except SafeHttpError as exc:
|
|
432
|
+
raise UpdateCheckError(f"update check blocked: {exc}") from exc
|
|
433
|
+
except httpx.TimeoutException as exc:
|
|
434
|
+
raise UpdateCheckError(f"update check timed out after {timeout_s:g}s") from exc
|
|
435
|
+
except Exception as exc: # noqa: BLE001
|
|
436
|
+
raise UpdateCheckError(f"update check failed: {exc}") from exc
|
|
437
|
+
|
|
438
|
+
if response.status_code == 404:
|
|
439
|
+
raise UpdateCheckError(f"No PyPI release found for {PYTHON_PACKAGE_NAME}.")
|
|
440
|
+
if response.status_code >= 400:
|
|
441
|
+
raise UpdateCheckError(f"PyPI update check failed with HTTP {response.status_code}")
|
|
442
|
+
try:
|
|
443
|
+
payload = response.json()
|
|
444
|
+
except Exception as exc: # noqa: BLE001
|
|
445
|
+
raise UpdateCheckError("PyPI update check returned non-JSON response") from exc
|
|
446
|
+
if not isinstance(payload, dict):
|
|
447
|
+
raise UpdateCheckError("PyPI update check returned unexpected payload")
|
|
448
|
+
info = payload.get("info")
|
|
449
|
+
if not isinstance(info, dict):
|
|
450
|
+
raise UpdateCheckError("PyPI update check payload is missing info")
|
|
451
|
+
latest_version = str(info.get("version") or "").strip()
|
|
452
|
+
if not latest_version:
|
|
453
|
+
raise UpdateCheckError("PyPI update check payload is missing latest version")
|
|
454
|
+
_parse_version(latest_version)
|
|
455
|
+
project_url = str(info.get("package_url") or "").strip() or None
|
|
456
|
+
if project_url is None:
|
|
457
|
+
project_urls = info.get("project_urls")
|
|
458
|
+
if isinstance(project_urls, dict):
|
|
459
|
+
project_url = str(
|
|
460
|
+
project_urls.get("Changelog") or project_urls.get("Source") or ""
|
|
461
|
+
).strip()
|
|
462
|
+
project_url = project_url or None
|
|
463
|
+
return latest_version, project_url
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def passive_update_notice(
|
|
467
|
+
*,
|
|
468
|
+
current_version: str,
|
|
469
|
+
cfg: AppConfig | None,
|
|
470
|
+
now: datetime | None = None,
|
|
471
|
+
path: Path | None = None,
|
|
472
|
+
) -> str | None:
|
|
473
|
+
status = status_from_cache(current_version=current_version, cfg=cfg, now=now, path=path)
|
|
474
|
+
if not status.update_available or not status.latest_version:
|
|
475
|
+
return None
|
|
476
|
+
return (
|
|
477
|
+
f"Alysis Code {status.latest_version} is available; you have {status.current_version}. "
|
|
478
|
+
"Run `alysis update`."
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def update_prompt_state_path() -> Path:
|
|
483
|
+
override = env_get("ALYSIS_UPDATE_PROMPT_STATE_PATH")
|
|
484
|
+
if override:
|
|
485
|
+
return Path(override)
|
|
486
|
+
data_override = env_get("ALYSIS_DATA_DIR")
|
|
487
|
+
data_dir = Path(data_override) if data_override else canonical_user_data_dir()
|
|
488
|
+
return data_dir / "update_prompt.json"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def resolve_update_prompt_enabled(cfg: AppConfig | None) -> bool:
|
|
492
|
+
env_value = env_get("ALYSIS_UPDATE_PROMPT_ENABLED")
|
|
493
|
+
if env_value is not None:
|
|
494
|
+
return _parse_bool(env_value, key="ALYSIS_UPDATE_PROMPT_ENABLED")
|
|
495
|
+
if cfg is None:
|
|
496
|
+
return True
|
|
497
|
+
return bool(getattr(cfg, "update_prompt_enabled", True))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def read_update_prompt_state(path: Path | None = None) -> UpdatePromptState:
|
|
501
|
+
state_path = path or update_prompt_state_path()
|
|
502
|
+
if not state_path.exists():
|
|
503
|
+
return UpdatePromptState()
|
|
504
|
+
try:
|
|
505
|
+
if state_path.stat().st_size > UPDATE_CACHE_MAX_BYTES:
|
|
506
|
+
return UpdatePromptState()
|
|
507
|
+
except OSError:
|
|
508
|
+
return UpdatePromptState()
|
|
509
|
+
try:
|
|
510
|
+
raw = json.loads(state_path.read_text(encoding="utf-8"))
|
|
511
|
+
except Exception:
|
|
512
|
+
return UpdatePromptState()
|
|
513
|
+
if not isinstance(raw, dict):
|
|
514
|
+
return UpdatePromptState()
|
|
515
|
+
try:
|
|
516
|
+
schema_version = int(raw.get("schema_version") or 0)
|
|
517
|
+
except (TypeError, ValueError):
|
|
518
|
+
return UpdatePromptState()
|
|
519
|
+
if schema_version != UPDATE_PROMPT_STATE_SCHEMA_VERSION:
|
|
520
|
+
return UpdatePromptState()
|
|
521
|
+
package = str(raw.get("package") or "").strip()
|
|
522
|
+
if package != PYTHON_PACKAGE_NAME:
|
|
523
|
+
return UpdatePromptState()
|
|
524
|
+
return UpdatePromptState(
|
|
525
|
+
skipped_version=_optional_non_empty_string(raw.get("skipped_version")),
|
|
526
|
+
last_prompted_version=_optional_non_empty_string(raw.get("last_prompted_version")),
|
|
527
|
+
last_prompted_at=_parse_datetime(raw.get("last_prompted_at")),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def write_update_prompt_state(state: UpdatePromptState, path: Path | None = None) -> None:
|
|
532
|
+
atomic_write_json(path or update_prompt_state_path(), state.to_json())
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def record_update_prompt_shown(
|
|
536
|
+
latest_version: str,
|
|
537
|
+
*,
|
|
538
|
+
path: Path | None = None,
|
|
539
|
+
now: datetime | None = None,
|
|
540
|
+
) -> None:
|
|
541
|
+
state = read_update_prompt_state(path)
|
|
542
|
+
write_update_prompt_state(
|
|
543
|
+
UpdatePromptState(
|
|
544
|
+
skipped_version=state.skipped_version,
|
|
545
|
+
last_prompted_version=latest_version,
|
|
546
|
+
last_prompted_at=_utcnow() if now is None else _ensure_utc(now),
|
|
547
|
+
),
|
|
548
|
+
path,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def record_update_skipped(
|
|
553
|
+
latest_version: str,
|
|
554
|
+
*,
|
|
555
|
+
path: Path | None = None,
|
|
556
|
+
now: datetime | None = None,
|
|
557
|
+
) -> None:
|
|
558
|
+
state = read_update_prompt_state(path)
|
|
559
|
+
write_update_prompt_state(
|
|
560
|
+
UpdatePromptState(
|
|
561
|
+
skipped_version=latest_version,
|
|
562
|
+
last_prompted_version=state.last_prompted_version,
|
|
563
|
+
last_prompted_at=state.last_prompted_at,
|
|
564
|
+
),
|
|
565
|
+
path,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def should_prompt_for_update(
|
|
570
|
+
*,
|
|
571
|
+
status: UpdateStatus,
|
|
572
|
+
state: UpdatePromptState,
|
|
573
|
+
cfg: AppConfig | None,
|
|
574
|
+
now: datetime | None = None,
|
|
575
|
+
) -> bool:
|
|
576
|
+
"""Pure gate for the startup update prompt.
|
|
577
|
+
|
|
578
|
+
True only when a newer release is known from the cache and the user has
|
|
579
|
+
neither skipped that release nor been prompted about it within the snooze
|
|
580
|
+
window.
|
|
581
|
+
"""
|
|
582
|
+
if not resolve_update_check_enabled(cfg):
|
|
583
|
+
return False
|
|
584
|
+
if not resolve_update_prompt_enabled(cfg):
|
|
585
|
+
return False
|
|
586
|
+
if not status.update_available or not status.latest_version:
|
|
587
|
+
return False
|
|
588
|
+
latest = status.latest_version
|
|
589
|
+
if state.skipped_version == latest:
|
|
590
|
+
return False
|
|
591
|
+
if state.last_prompted_version == latest and state.last_prompted_at is not None:
|
|
592
|
+
current = _utcnow() if now is None else _ensure_utc(now)
|
|
593
|
+
age = current - state.last_prompted_at
|
|
594
|
+
if timedelta(0) <= age < timedelta(hours=DEFAULT_UPDATE_PROMPT_SNOOZE_HOURS):
|
|
595
|
+
return False
|
|
596
|
+
return True
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def installed_distribution_name(preferred: str = PYTHON_PACKAGE_NAME) -> str:
|
|
600
|
+
"""Return whichever distribution is actually installed.
|
|
601
|
+
|
|
602
|
+
An install predating the rename is registered as ``sylliptor-agent-cli``.
|
|
603
|
+
Introspection keyed on the new name would find nothing and report the
|
|
604
|
+
package as missing.
|
|
605
|
+
"""
|
|
606
|
+
for name in (preferred, LEGACY_PYTHON_PACKAGE_NAME):
|
|
607
|
+
try:
|
|
608
|
+
importlib_metadata.distribution(name)
|
|
609
|
+
except importlib_metadata.PackageNotFoundError:
|
|
610
|
+
continue
|
|
611
|
+
return name
|
|
612
|
+
return preferred
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def detect_installer_plan(
|
|
616
|
+
*,
|
|
617
|
+
package_name: str = PYTHON_PACKAGE_NAME,
|
|
618
|
+
executable: str | None = None,
|
|
619
|
+
prefix: str | None = None,
|
|
620
|
+
base_prefix: str | None = None,
|
|
621
|
+
env: Mapping[str, str] | None = None,
|
|
622
|
+
) -> InstallerPlan:
|
|
623
|
+
executable_value = executable or sys.executable
|
|
624
|
+
prefix_value = prefix or sys.prefix
|
|
625
|
+
base_prefix_value = base_prefix or getattr(sys, "base_prefix", sys.prefix)
|
|
626
|
+
env_map = env or os.environ
|
|
627
|
+
|
|
628
|
+
# Introspect the distribution that is really on disk, but always upgrade
|
|
629
|
+
# *to* the current package. Upgrading the legacy name in place would keep
|
|
630
|
+
# the user on a package that no longer receives releases; upgrading the new
|
|
631
|
+
# name without replacing the old one would leave two copies fighting over
|
|
632
|
+
# the same console script.
|
|
633
|
+
installed_name = installed_distribution_name(package_name)
|
|
634
|
+
if installed_name != package_name:
|
|
635
|
+
return _legacy_distribution_plan(
|
|
636
|
+
installed_name=installed_name,
|
|
637
|
+
target_name=package_name,
|
|
638
|
+
executable=executable_value,
|
|
639
|
+
prefix=prefix_value,
|
|
640
|
+
env=env_map,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
editable_reason = _editable_install_reason(package_name)
|
|
644
|
+
if editable_reason:
|
|
645
|
+
return InstallerPlan(
|
|
646
|
+
method="editable",
|
|
647
|
+
supported=False,
|
|
648
|
+
reason=editable_reason,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
if _looks_like_pipx_install(prefix_value, executable_value, package_name, env_map):
|
|
652
|
+
if shutil.which("pipx") is None:
|
|
653
|
+
return InstallerPlan(
|
|
654
|
+
method="pipx",
|
|
655
|
+
supported=False,
|
|
656
|
+
reason="This looks like a pipx install, but `pipx` is not on PATH.",
|
|
657
|
+
)
|
|
658
|
+
return InstallerPlan(
|
|
659
|
+
method="pipx",
|
|
660
|
+
supported=True,
|
|
661
|
+
command=("pipx", "upgrade", package_name),
|
|
662
|
+
reason="Detected a pipx-managed virtual environment.",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
installer = _distribution_installer(package_name)
|
|
666
|
+
if installer == "uv":
|
|
667
|
+
return _uv_installer_plan(
|
|
668
|
+
package_name=package_name,
|
|
669
|
+
executable=executable_value,
|
|
670
|
+
prefix=prefix_value,
|
|
671
|
+
env=env_map,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
command = (executable_value, "-m", "pip", "install", "--upgrade", package_name)
|
|
675
|
+
if prefix_value != base_prefix_value:
|
|
676
|
+
return InstallerPlan(
|
|
677
|
+
method="venv-pip",
|
|
678
|
+
supported=True,
|
|
679
|
+
command=command,
|
|
680
|
+
reason="Detected a Python virtual environment.",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if installer and installer != "pip":
|
|
684
|
+
return InstallerPlan(
|
|
685
|
+
method=installer,
|
|
686
|
+
supported=False,
|
|
687
|
+
reason=f"This install reports installer `{installer}`; update it with that tool.",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
return InstallerPlan(
|
|
691
|
+
method="pip",
|
|
692
|
+
supported=True,
|
|
693
|
+
command=command,
|
|
694
|
+
reason="Detected a pip-style Python install.",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _uv_installer_plan(
|
|
699
|
+
*,
|
|
700
|
+
package_name: str,
|
|
701
|
+
executable: str,
|
|
702
|
+
prefix: str,
|
|
703
|
+
env: Mapping[str, str],
|
|
704
|
+
) -> InstallerPlan:
|
|
705
|
+
if shutil.which("uv") is None:
|
|
706
|
+
return InstallerPlan(
|
|
707
|
+
method="uv",
|
|
708
|
+
supported=False,
|
|
709
|
+
reason="This install reports installer `uv`, but `uv` is not on PATH.",
|
|
710
|
+
)
|
|
711
|
+
if _looks_like_uv_tool_install(prefix, package_name, env):
|
|
712
|
+
return InstallerPlan(
|
|
713
|
+
method="uv-tool",
|
|
714
|
+
supported=True,
|
|
715
|
+
command=("uv", "tool", "upgrade", package_name),
|
|
716
|
+
reason="Detected a uv-managed tool install.",
|
|
717
|
+
)
|
|
718
|
+
return InstallerPlan(
|
|
719
|
+
method="uv-pip",
|
|
720
|
+
supported=True,
|
|
721
|
+
command=("uv", "pip", "install", "--python", executable, "--upgrade", package_name),
|
|
722
|
+
reason="Detected a uv-managed Python environment.",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def run_installer_plan(
|
|
727
|
+
plan: InstallerPlan,
|
|
728
|
+
*,
|
|
729
|
+
runner: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
|
|
730
|
+
) -> int:
|
|
731
|
+
if not plan.supported or not plan.command:
|
|
732
|
+
raise UpdateCheckError(plan.reason or "No supported update command is available.")
|
|
733
|
+
completed = runner(list(plan.command), check=False)
|
|
734
|
+
return int(getattr(completed, "returncode", 0) or 0)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _looks_like_pipx_install(
|
|
738
|
+
prefix: str,
|
|
739
|
+
executable: str,
|
|
740
|
+
package_name: str,
|
|
741
|
+
env: Mapping[str, str],
|
|
742
|
+
) -> bool:
|
|
743
|
+
package_variants = {_normalize_package_name(package_name), "alysis"}
|
|
744
|
+
for raw_path in (prefix, executable):
|
|
745
|
+
parts = [part.casefold() for part in Path(raw_path).parts]
|
|
746
|
+
for index, part in enumerate(parts):
|
|
747
|
+
if part != "pipx":
|
|
748
|
+
continue
|
|
749
|
+
if index + 2 < len(parts) and parts[index + 1] == "venvs":
|
|
750
|
+
return _normalize_package_name(parts[index + 2]) in package_variants
|
|
751
|
+
pipx_home = str(env.get("PIPX_HOME") or "").strip()
|
|
752
|
+
return bool(pipx_home and str(Path(prefix)).startswith(os.fspath(Path(pipx_home))))
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _looks_like_uv_tool_install(prefix: str, package_name: str, env: Mapping[str, str]) -> bool:
|
|
756
|
+
package_variants = {_normalize_package_name(package_name), "alysis"}
|
|
757
|
+
prefix_path = Path(prefix)
|
|
758
|
+
for configured_root in _uv_tool_roots(env):
|
|
759
|
+
try:
|
|
760
|
+
relative = prefix_path.resolve().relative_to(configured_root.resolve())
|
|
761
|
+
except (OSError, ValueError):
|
|
762
|
+
continue
|
|
763
|
+
first_part = relative.parts[0] if relative.parts else ""
|
|
764
|
+
if _normalize_package_name(first_part) in package_variants:
|
|
765
|
+
return True
|
|
766
|
+
|
|
767
|
+
parts = [part.casefold() for part in prefix_path.parts]
|
|
768
|
+
for index, part in enumerate(parts):
|
|
769
|
+
if part == "uv" and index + 2 < len(parts) and parts[index + 1] == "tools":
|
|
770
|
+
return _normalize_package_name(parts[index + 2]) in package_variants
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _uv_tool_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
|
|
775
|
+
raw_roots = [
|
|
776
|
+
str(env.get("UV_TOOL_DIR") or "").strip(),
|
|
777
|
+
os.fspath(Path.home() / ".local" / "share" / "uv" / "tools"),
|
|
778
|
+
]
|
|
779
|
+
return tuple(Path(raw) for raw in raw_roots if raw)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _legacy_distribution_plan(
|
|
783
|
+
*,
|
|
784
|
+
installed_name: str,
|
|
785
|
+
target_name: str,
|
|
786
|
+
executable: str,
|
|
787
|
+
prefix: str,
|
|
788
|
+
env: Mapping[str, str],
|
|
789
|
+
) -> InstallerPlan:
|
|
790
|
+
"""Plan the move from the pre-rebrand distribution to the current one."""
|
|
791
|
+
if _editable_install_reason(installed_name):
|
|
792
|
+
return InstallerPlan(
|
|
793
|
+
method="editable",
|
|
794
|
+
supported=False,
|
|
795
|
+
reason=(
|
|
796
|
+
f"This is an editable install of `{installed_name}`. Pull the renamed "
|
|
797
|
+
f"source and reinstall as `{target_name}`."
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
if _looks_like_pipx_install(prefix, executable, installed_name, env):
|
|
802
|
+
if shutil.which("pipx") is None:
|
|
803
|
+
return InstallerPlan(
|
|
804
|
+
method="pipx",
|
|
805
|
+
supported=False,
|
|
806
|
+
reason="This looks like a pipx install, but `pipx` is not on PATH.",
|
|
807
|
+
)
|
|
808
|
+
# `pipx upgrade` cannot rename an app; the old one must go first or its
|
|
809
|
+
# `sylliptor` script would shadow the new package's alias.
|
|
810
|
+
return InstallerPlan(
|
|
811
|
+
method="pipx",
|
|
812
|
+
supported=False,
|
|
813
|
+
command=("pipx", "uninstall", installed_name),
|
|
814
|
+
reason=(
|
|
815
|
+
f"`{installed_name}` was renamed to `{target_name}`. Run "
|
|
816
|
+
f"`pipx uninstall {installed_name}` then `pipx install {target_name}`."
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
return InstallerPlan(
|
|
821
|
+
method="venv-pip",
|
|
822
|
+
supported=False,
|
|
823
|
+
command=(executable, "-m", "pip", "install", "--upgrade", target_name),
|
|
824
|
+
reason=(
|
|
825
|
+
f"`{installed_name}` was renamed to `{target_name}`. Install the new package, "
|
|
826
|
+
f"then `pip uninstall {installed_name}` to remove the old one."
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _editable_install_reason(package_name: str) -> str | None:
|
|
832
|
+
try:
|
|
833
|
+
dist = importlib_metadata.distribution(package_name)
|
|
834
|
+
except importlib_metadata.PackageNotFoundError:
|
|
835
|
+
return "Package metadata was not found; update from the source checkout or reinstall."
|
|
836
|
+
raw = dist.read_text("direct_url.json")
|
|
837
|
+
if not raw:
|
|
838
|
+
return None
|
|
839
|
+
try:
|
|
840
|
+
payload = json.loads(raw)
|
|
841
|
+
except json.JSONDecodeError:
|
|
842
|
+
return None
|
|
843
|
+
if not isinstance(payload, dict):
|
|
844
|
+
return None
|
|
845
|
+
dir_info = payload.get("dir_info")
|
|
846
|
+
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
|
|
847
|
+
url = str(payload.get("url") or "").strip()
|
|
848
|
+
suffix = f" ({url})" if url else ""
|
|
849
|
+
return f"This is an editable/source install{suffix}; update the checkout manually."
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _distribution_installer(package_name: str) -> str | None:
|
|
854
|
+
try:
|
|
855
|
+
dist = importlib_metadata.distribution(package_name)
|
|
856
|
+
except importlib_metadata.PackageNotFoundError:
|
|
857
|
+
return None
|
|
858
|
+
raw = dist.read_text("INSTALLER")
|
|
859
|
+
value = str(raw or "").strip().lower()
|
|
860
|
+
return value or None
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _normalize_package_name(value: str) -> str:
|
|
864
|
+
return str(value or "").strip().replace("_", "-").casefold()
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _version_is_newer(latest: str, current: str) -> bool:
|
|
868
|
+
try:
|
|
869
|
+
return _parse_version(latest) > _parse_version(current)
|
|
870
|
+
except InvalidVersion:
|
|
871
|
+
return False
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _parse_version(value: str) -> Version:
|
|
875
|
+
try:
|
|
876
|
+
return Version(str(value or "").strip())
|
|
877
|
+
except InvalidVersion as exc:
|
|
878
|
+
raise UpdateCheckError(f"Invalid package version: {value!r}") from exc
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _parse_bool(value: object, *, key: str) -> bool:
|
|
882
|
+
normalized = str(value).strip().lower()
|
|
883
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
884
|
+
return True
|
|
885
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
886
|
+
return False
|
|
887
|
+
raise ConfigError(f"{key} must be true/false")
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _parse_positive_int(value: object, *, key: str) -> int:
|
|
891
|
+
try:
|
|
892
|
+
parsed = int(value)
|
|
893
|
+
except (TypeError, ValueError) as exc:
|
|
894
|
+
raise ConfigError(f"{key} must be an integer") from exc
|
|
895
|
+
if parsed <= 0:
|
|
896
|
+
raise ConfigError(f"{key} must be > 0")
|
|
897
|
+
return parsed
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _parse_positive_float(value: object, *, key: str) -> float:
|
|
901
|
+
try:
|
|
902
|
+
parsed = float(value)
|
|
903
|
+
except (TypeError, ValueError) as exc:
|
|
904
|
+
raise ConfigError(f"{key} must be a number") from exc
|
|
905
|
+
if parsed <= 0:
|
|
906
|
+
raise ConfigError(f"{key} must be > 0")
|
|
907
|
+
return parsed
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _optional_non_empty_string(value: object) -> str | None:
|
|
911
|
+
text = str(value or "").strip()
|
|
912
|
+
return text or None
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _parse_datetime(value: object) -> datetime | None:
|
|
916
|
+
text = str(value or "").strip()
|
|
917
|
+
if not text:
|
|
918
|
+
return None
|
|
919
|
+
try:
|
|
920
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
921
|
+
except ValueError:
|
|
922
|
+
return None
|
|
923
|
+
return _ensure_utc(parsed)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _ensure_utc(value: datetime) -> datetime:
|
|
927
|
+
if value.tzinfo is None:
|
|
928
|
+
return value.replace(tzinfo=UTC)
|
|
929
|
+
return value.astimezone(UTC)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _utcnow() -> datetime:
|
|
933
|
+
return datetime.now(UTC)
|