attune-ai 2.0.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.
- attune/__init__.py +358 -0
- attune/adaptive/__init__.py +13 -0
- attune/adaptive/task_complexity.py +127 -0
- attune/agent_monitoring.py +414 -0
- attune/cache/__init__.py +117 -0
- attune/cache/base.py +166 -0
- attune/cache/dependency_manager.py +256 -0
- attune/cache/hash_only.py +251 -0
- attune/cache/hybrid.py +457 -0
- attune/cache/storage.py +285 -0
- attune/cache_monitor.py +356 -0
- attune/cache_stats.py +298 -0
- attune/cli/__init__.py +152 -0
- attune/cli/__main__.py +12 -0
- attune/cli/commands/__init__.py +1 -0
- attune/cli/commands/batch.py +264 -0
- attune/cli/commands/cache.py +248 -0
- attune/cli/commands/help.py +331 -0
- attune/cli/commands/info.py +140 -0
- attune/cli/commands/inspect.py +436 -0
- attune/cli/commands/inspection.py +57 -0
- attune/cli/commands/memory.py +48 -0
- attune/cli/commands/metrics.py +92 -0
- attune/cli/commands/orchestrate.py +184 -0
- attune/cli/commands/patterns.py +207 -0
- attune/cli/commands/profiling.py +202 -0
- attune/cli/commands/provider.py +98 -0
- attune/cli/commands/routing.py +285 -0
- attune/cli/commands/setup.py +96 -0
- attune/cli/commands/status.py +235 -0
- attune/cli/commands/sync.py +166 -0
- attune/cli/commands/tier.py +121 -0
- attune/cli/commands/utilities.py +114 -0
- attune/cli/commands/workflow.py +579 -0
- attune/cli/core.py +32 -0
- attune/cli/parsers/__init__.py +68 -0
- attune/cli/parsers/batch.py +118 -0
- attune/cli/parsers/cache.py +65 -0
- attune/cli/parsers/help.py +41 -0
- attune/cli/parsers/info.py +26 -0
- attune/cli/parsers/inspect.py +66 -0
- attune/cli/parsers/metrics.py +42 -0
- attune/cli/parsers/orchestrate.py +61 -0
- attune/cli/parsers/patterns.py +54 -0
- attune/cli/parsers/provider.py +40 -0
- attune/cli/parsers/routing.py +110 -0
- attune/cli/parsers/setup.py +42 -0
- attune/cli/parsers/status.py +47 -0
- attune/cli/parsers/sync.py +31 -0
- attune/cli/parsers/tier.py +33 -0
- attune/cli/parsers/workflow.py +77 -0
- attune/cli/utils/__init__.py +1 -0
- attune/cli/utils/data.py +242 -0
- attune/cli/utils/helpers.py +68 -0
- attune/cli_legacy.py +3957 -0
- attune/cli_minimal.py +1159 -0
- attune/cli_router.py +437 -0
- attune/cli_unified.py +814 -0
- attune/config/__init__.py +66 -0
- attune/config/xml_config.py +286 -0
- attune/config.py +545 -0
- attune/coordination.py +870 -0
- attune/core.py +1511 -0
- attune/core_modules/__init__.py +15 -0
- attune/cost_tracker.py +626 -0
- attune/dashboard/__init__.py +41 -0
- attune/dashboard/app.py +512 -0
- attune/dashboard/simple_server.py +435 -0
- attune/dashboard/standalone_server.py +547 -0
- attune/discovery.py +306 -0
- attune/emergence.py +306 -0
- attune/exceptions.py +123 -0
- attune/feedback_loops.py +373 -0
- attune/hot_reload/README.md +473 -0
- attune/hot_reload/__init__.py +62 -0
- attune/hot_reload/config.py +83 -0
- attune/hot_reload/integration.py +229 -0
- attune/hot_reload/reloader.py +298 -0
- attune/hot_reload/watcher.py +183 -0
- attune/hot_reload/websocket.py +177 -0
- attune/levels.py +577 -0
- attune/leverage_points.py +441 -0
- attune/logging_config.py +261 -0
- attune/mcp/__init__.py +10 -0
- attune/mcp/server.py +506 -0
- attune/memory/__init__.py +237 -0
- attune/memory/claude_memory.py +469 -0
- attune/memory/config.py +224 -0
- attune/memory/control_panel.py +1290 -0
- attune/memory/control_panel_support.py +145 -0
- attune/memory/cross_session.py +845 -0
- attune/memory/edges.py +179 -0
- attune/memory/encryption.py +159 -0
- attune/memory/file_session.py +770 -0
- attune/memory/graph.py +570 -0
- attune/memory/long_term.py +913 -0
- attune/memory/long_term_types.py +99 -0
- attune/memory/mixins/__init__.py +25 -0
- attune/memory/mixins/backend_init_mixin.py +249 -0
- attune/memory/mixins/capabilities_mixin.py +208 -0
- attune/memory/mixins/handoff_mixin.py +208 -0
- attune/memory/mixins/lifecycle_mixin.py +49 -0
- attune/memory/mixins/long_term_mixin.py +352 -0
- attune/memory/mixins/promotion_mixin.py +109 -0
- attune/memory/mixins/short_term_mixin.py +182 -0
- attune/memory/nodes.py +179 -0
- attune/memory/redis_bootstrap.py +540 -0
- attune/memory/security/__init__.py +31 -0
- attune/memory/security/audit_logger.py +932 -0
- attune/memory/security/pii_scrubber.py +640 -0
- attune/memory/security/secrets_detector.py +678 -0
- attune/memory/short_term.py +2192 -0
- attune/memory/simple_storage.py +302 -0
- attune/memory/storage/__init__.py +15 -0
- attune/memory/storage_backend.py +167 -0
- attune/memory/summary_index.py +583 -0
- attune/memory/types.py +446 -0
- attune/memory/unified.py +182 -0
- attune/meta_workflows/__init__.py +74 -0
- attune/meta_workflows/agent_creator.py +248 -0
- attune/meta_workflows/builtin_templates.py +567 -0
- attune/meta_workflows/cli_commands/__init__.py +56 -0
- attune/meta_workflows/cli_commands/agent_commands.py +321 -0
- attune/meta_workflows/cli_commands/analytics_commands.py +442 -0
- attune/meta_workflows/cli_commands/config_commands.py +232 -0
- attune/meta_workflows/cli_commands/memory_commands.py +182 -0
- attune/meta_workflows/cli_commands/template_commands.py +354 -0
- attune/meta_workflows/cli_commands/workflow_commands.py +382 -0
- attune/meta_workflows/cli_meta_workflows.py +59 -0
- attune/meta_workflows/form_engine.py +292 -0
- attune/meta_workflows/intent_detector.py +409 -0
- attune/meta_workflows/models.py +569 -0
- attune/meta_workflows/pattern_learner.py +738 -0
- attune/meta_workflows/plan_generator.py +384 -0
- attune/meta_workflows/session_context.py +397 -0
- attune/meta_workflows/template_registry.py +229 -0
- attune/meta_workflows/workflow.py +984 -0
- attune/metrics/__init__.py +12 -0
- attune/metrics/collector.py +31 -0
- attune/metrics/prompt_metrics.py +194 -0
- attune/models/__init__.py +172 -0
- attune/models/__main__.py +13 -0
- attune/models/adaptive_routing.py +437 -0
- attune/models/auth_cli.py +444 -0
- attune/models/auth_strategy.py +450 -0
- attune/models/cli.py +655 -0
- attune/models/empathy_executor.py +354 -0
- attune/models/executor.py +257 -0
- attune/models/fallback.py +762 -0
- attune/models/provider_config.py +282 -0
- attune/models/registry.py +472 -0
- attune/models/tasks.py +359 -0
- attune/models/telemetry/__init__.py +71 -0
- attune/models/telemetry/analytics.py +594 -0
- attune/models/telemetry/backend.py +196 -0
- attune/models/telemetry/data_models.py +431 -0
- attune/models/telemetry/storage.py +489 -0
- attune/models/token_estimator.py +420 -0
- attune/models/validation.py +280 -0
- attune/monitoring/__init__.py +52 -0
- attune/monitoring/alerts.py +946 -0
- attune/monitoring/alerts_cli.py +448 -0
- attune/monitoring/multi_backend.py +271 -0
- attune/monitoring/otel_backend.py +362 -0
- attune/optimization/__init__.py +19 -0
- attune/optimization/context_optimizer.py +272 -0
- attune/orchestration/__init__.py +67 -0
- attune/orchestration/agent_templates.py +707 -0
- attune/orchestration/config_store.py +499 -0
- attune/orchestration/execution_strategies.py +2111 -0
- attune/orchestration/meta_orchestrator.py +1168 -0
- attune/orchestration/pattern_learner.py +696 -0
- attune/orchestration/real_tools.py +931 -0
- attune/pattern_cache.py +187 -0
- attune/pattern_library.py +542 -0
- attune/patterns/debugging/all_patterns.json +81 -0
- attune/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- attune/patterns/refactoring_memory.json +89 -0
- attune/persistence.py +564 -0
- attune/platform_utils.py +265 -0
- attune/plugins/__init__.py +28 -0
- attune/plugins/base.py +361 -0
- attune/plugins/registry.py +268 -0
- attune/project_index/__init__.py +32 -0
- attune/project_index/cli.py +335 -0
- attune/project_index/index.py +667 -0
- attune/project_index/models.py +504 -0
- attune/project_index/reports.py +474 -0
- attune/project_index/scanner.py +777 -0
- attune/project_index/scanner_parallel.py +291 -0
- attune/prompts/__init__.py +61 -0
- attune/prompts/config.py +77 -0
- attune/prompts/context.py +177 -0
- attune/prompts/parser.py +285 -0
- attune/prompts/registry.py +313 -0
- attune/prompts/templates.py +208 -0
- attune/redis_config.py +302 -0
- attune/redis_memory.py +799 -0
- attune/resilience/__init__.py +56 -0
- attune/resilience/circuit_breaker.py +256 -0
- attune/resilience/fallback.py +179 -0
- attune/resilience/health.py +300 -0
- attune/resilience/retry.py +209 -0
- attune/resilience/timeout.py +135 -0
- attune/routing/__init__.py +43 -0
- attune/routing/chain_executor.py +433 -0
- attune/routing/classifier.py +217 -0
- attune/routing/smart_router.py +234 -0
- attune/routing/workflow_registry.py +343 -0
- attune/scaffolding/README.md +589 -0
- attune/scaffolding/__init__.py +35 -0
- attune/scaffolding/__main__.py +14 -0
- attune/scaffolding/cli.py +240 -0
- attune/scaffolding/templates/base_wizard.py.jinja2 +121 -0
- attune/scaffolding/templates/coach_wizard.py.jinja2 +321 -0
- attune/scaffolding/templates/domain_wizard.py.jinja2 +408 -0
- attune/scaffolding/templates/linear_flow_wizard.py.jinja2 +203 -0
- attune/socratic/__init__.py +256 -0
- attune/socratic/ab_testing.py +958 -0
- attune/socratic/blueprint.py +533 -0
- attune/socratic/cli.py +703 -0
- attune/socratic/collaboration.py +1114 -0
- attune/socratic/domain_templates.py +924 -0
- attune/socratic/embeddings.py +738 -0
- attune/socratic/engine.py +794 -0
- attune/socratic/explainer.py +682 -0
- attune/socratic/feedback.py +772 -0
- attune/socratic/forms.py +629 -0
- attune/socratic/generator.py +732 -0
- attune/socratic/llm_analyzer.py +637 -0
- attune/socratic/mcp_server.py +702 -0
- attune/socratic/session.py +312 -0
- attune/socratic/storage.py +667 -0
- attune/socratic/success.py +730 -0
- attune/socratic/visual_editor.py +860 -0
- attune/socratic/web_ui.py +958 -0
- attune/telemetry/__init__.py +39 -0
- attune/telemetry/agent_coordination.py +475 -0
- attune/telemetry/agent_tracking.py +367 -0
- attune/telemetry/approval_gates.py +545 -0
- attune/telemetry/cli.py +1231 -0
- attune/telemetry/commands/__init__.py +14 -0
- attune/telemetry/commands/dashboard_commands.py +696 -0
- attune/telemetry/event_streaming.py +409 -0
- attune/telemetry/feedback_loop.py +567 -0
- attune/telemetry/usage_tracker.py +591 -0
- attune/templates.py +754 -0
- attune/test_generator/__init__.py +38 -0
- attune/test_generator/__main__.py +14 -0
- attune/test_generator/cli.py +234 -0
- attune/test_generator/generator.py +355 -0
- attune/test_generator/risk_analyzer.py +216 -0
- attune/test_generator/templates/unit_test.py.jinja2 +272 -0
- attune/tier_recommender.py +384 -0
- attune/tools.py +183 -0
- attune/trust/__init__.py +28 -0
- attune/trust/circuit_breaker.py +579 -0
- attune/trust_building.py +527 -0
- attune/validation/__init__.py +19 -0
- attune/validation/xml_validator.py +281 -0
- attune/vscode_bridge.py +173 -0
- attune/workflow_commands.py +780 -0
- attune/workflow_patterns/__init__.py +33 -0
- attune/workflow_patterns/behavior.py +249 -0
- attune/workflow_patterns/core.py +76 -0
- attune/workflow_patterns/output.py +99 -0
- attune/workflow_patterns/registry.py +255 -0
- attune/workflow_patterns/structural.py +288 -0
- attune/workflows/__init__.py +539 -0
- attune/workflows/autonomous_test_gen.py +1268 -0
- attune/workflows/base.py +2667 -0
- attune/workflows/batch_processing.py +342 -0
- attune/workflows/bug_predict.py +1084 -0
- attune/workflows/builder.py +273 -0
- attune/workflows/caching.py +253 -0
- attune/workflows/code_review.py +1048 -0
- attune/workflows/code_review_adapters.py +312 -0
- attune/workflows/code_review_pipeline.py +722 -0
- attune/workflows/config.py +645 -0
- attune/workflows/dependency_check.py +644 -0
- attune/workflows/document_gen/__init__.py +25 -0
- attune/workflows/document_gen/config.py +30 -0
- attune/workflows/document_gen/report_formatter.py +162 -0
- attune/workflows/document_gen/workflow.py +1426 -0
- attune/workflows/document_manager.py +216 -0
- attune/workflows/document_manager_README.md +134 -0
- attune/workflows/documentation_orchestrator.py +1205 -0
- attune/workflows/history.py +510 -0
- attune/workflows/keyboard_shortcuts/__init__.py +39 -0
- attune/workflows/keyboard_shortcuts/generators.py +391 -0
- attune/workflows/keyboard_shortcuts/parsers.py +416 -0
- attune/workflows/keyboard_shortcuts/prompts.py +295 -0
- attune/workflows/keyboard_shortcuts/schema.py +193 -0
- attune/workflows/keyboard_shortcuts/workflow.py +509 -0
- attune/workflows/llm_base.py +363 -0
- attune/workflows/manage_docs.py +87 -0
- attune/workflows/manage_docs_README.md +134 -0
- attune/workflows/manage_documentation.py +821 -0
- attune/workflows/new_sample_workflow1.py +149 -0
- attune/workflows/new_sample_workflow1_README.md +150 -0
- attune/workflows/orchestrated_health_check.py +849 -0
- attune/workflows/orchestrated_release_prep.py +600 -0
- attune/workflows/output.py +413 -0
- attune/workflows/perf_audit.py +863 -0
- attune/workflows/pr_review.py +762 -0
- attune/workflows/progress.py +785 -0
- attune/workflows/progress_server.py +322 -0
- attune/workflows/progressive/README 2.md +454 -0
- attune/workflows/progressive/README.md +454 -0
- attune/workflows/progressive/__init__.py +82 -0
- attune/workflows/progressive/cli.py +219 -0
- attune/workflows/progressive/core.py +488 -0
- attune/workflows/progressive/orchestrator.py +723 -0
- attune/workflows/progressive/reports.py +520 -0
- attune/workflows/progressive/telemetry.py +274 -0
- attune/workflows/progressive/test_gen.py +495 -0
- attune/workflows/progressive/workflow.py +589 -0
- attune/workflows/refactor_plan.py +694 -0
- attune/workflows/release_prep.py +895 -0
- attune/workflows/release_prep_crew.py +969 -0
- attune/workflows/research_synthesis.py +404 -0
- attune/workflows/routing.py +168 -0
- attune/workflows/secure_release.py +593 -0
- attune/workflows/security_adapters.py +297 -0
- attune/workflows/security_audit.py +1329 -0
- attune/workflows/security_audit_phase3.py +355 -0
- attune/workflows/seo_optimization.py +633 -0
- attune/workflows/step_config.py +234 -0
- attune/workflows/telemetry_mixin.py +269 -0
- attune/workflows/test5.py +125 -0
- attune/workflows/test5_README.md +158 -0
- attune/workflows/test_coverage_boost_crew.py +849 -0
- attune/workflows/test_gen/__init__.py +52 -0
- attune/workflows/test_gen/ast_analyzer.py +249 -0
- attune/workflows/test_gen/config.py +88 -0
- attune/workflows/test_gen/data_models.py +38 -0
- attune/workflows/test_gen/report_formatter.py +289 -0
- attune/workflows/test_gen/test_templates.py +381 -0
- attune/workflows/test_gen/workflow.py +655 -0
- attune/workflows/test_gen.py +54 -0
- attune/workflows/test_gen_behavioral.py +477 -0
- attune/workflows/test_gen_parallel.py +341 -0
- attune/workflows/test_lifecycle.py +526 -0
- attune/workflows/test_maintenance.py +627 -0
- attune/workflows/test_maintenance_cli.py +590 -0
- attune/workflows/test_maintenance_crew.py +840 -0
- attune/workflows/test_runner.py +622 -0
- attune/workflows/tier_tracking.py +531 -0
- attune/workflows/xml_enhanced_crew.py +285 -0
- attune_ai-2.0.0.dist-info/METADATA +1026 -0
- attune_ai-2.0.0.dist-info/RECORD +457 -0
- attune_ai-2.0.0.dist-info/WHEEL +5 -0
- attune_ai-2.0.0.dist-info/entry_points.txt +26 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE +201 -0
- attune_ai-2.0.0.dist-info/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +101 -0
- attune_ai-2.0.0.dist-info/top_level.txt +5 -0
- attune_healthcare/__init__.py +13 -0
- attune_healthcare/monitors/__init__.py +9 -0
- attune_healthcare/monitors/clinical_protocol_monitor.py +315 -0
- attune_healthcare/monitors/monitoring/__init__.py +44 -0
- attune_healthcare/monitors/monitoring/protocol_checker.py +300 -0
- attune_healthcare/monitors/monitoring/protocol_loader.py +214 -0
- attune_healthcare/monitors/monitoring/sensor_parsers.py +306 -0
- attune_healthcare/monitors/monitoring/trajectory_analyzer.py +389 -0
- attune_llm/README.md +553 -0
- attune_llm/__init__.py +28 -0
- attune_llm/agent_factory/__init__.py +53 -0
- attune_llm/agent_factory/adapters/__init__.py +85 -0
- attune_llm/agent_factory/adapters/autogen_adapter.py +312 -0
- attune_llm/agent_factory/adapters/crewai_adapter.py +483 -0
- attune_llm/agent_factory/adapters/haystack_adapter.py +298 -0
- attune_llm/agent_factory/adapters/langchain_adapter.py +362 -0
- attune_llm/agent_factory/adapters/langgraph_adapter.py +333 -0
- attune_llm/agent_factory/adapters/native.py +228 -0
- attune_llm/agent_factory/adapters/wizard_adapter.py +423 -0
- attune_llm/agent_factory/base.py +305 -0
- attune_llm/agent_factory/crews/__init__.py +67 -0
- attune_llm/agent_factory/crews/code_review.py +1113 -0
- attune_llm/agent_factory/crews/health_check.py +1262 -0
- attune_llm/agent_factory/crews/refactoring.py +1128 -0
- attune_llm/agent_factory/crews/security_audit.py +1018 -0
- attune_llm/agent_factory/decorators.py +287 -0
- attune_llm/agent_factory/factory.py +558 -0
- attune_llm/agent_factory/framework.py +193 -0
- attune_llm/agent_factory/memory_integration.py +328 -0
- attune_llm/agent_factory/resilient.py +320 -0
- attune_llm/agents_md/__init__.py +22 -0
- attune_llm/agents_md/loader.py +218 -0
- attune_llm/agents_md/parser.py +271 -0
- attune_llm/agents_md/registry.py +307 -0
- attune_llm/claude_memory.py +466 -0
- attune_llm/cli/__init__.py +8 -0
- attune_llm/cli/sync_claude.py +487 -0
- attune_llm/code_health.py +1313 -0
- attune_llm/commands/__init__.py +51 -0
- attune_llm/commands/context.py +375 -0
- attune_llm/commands/loader.py +301 -0
- attune_llm/commands/models.py +231 -0
- attune_llm/commands/parser.py +371 -0
- attune_llm/commands/registry.py +429 -0
- attune_llm/config/__init__.py +29 -0
- attune_llm/config/unified.py +291 -0
- attune_llm/context/__init__.py +22 -0
- attune_llm/context/compaction.py +455 -0
- attune_llm/context/manager.py +434 -0
- attune_llm/contextual_patterns.py +361 -0
- attune_llm/core.py +907 -0
- attune_llm/git_pattern_extractor.py +435 -0
- attune_llm/hooks/__init__.py +24 -0
- attune_llm/hooks/config.py +306 -0
- attune_llm/hooks/executor.py +289 -0
- attune_llm/hooks/registry.py +302 -0
- attune_llm/hooks/scripts/__init__.py +39 -0
- attune_llm/hooks/scripts/evaluate_session.py +201 -0
- attune_llm/hooks/scripts/first_time_init.py +285 -0
- attune_llm/hooks/scripts/pre_compact.py +207 -0
- attune_llm/hooks/scripts/session_end.py +183 -0
- attune_llm/hooks/scripts/session_start.py +163 -0
- attune_llm/hooks/scripts/suggest_compact.py +225 -0
- attune_llm/learning/__init__.py +30 -0
- attune_llm/learning/evaluator.py +438 -0
- attune_llm/learning/extractor.py +514 -0
- attune_llm/learning/storage.py +560 -0
- attune_llm/levels.py +227 -0
- attune_llm/pattern_confidence.py +414 -0
- attune_llm/pattern_resolver.py +272 -0
- attune_llm/pattern_summary.py +350 -0
- attune_llm/providers.py +967 -0
- attune_llm/routing/__init__.py +32 -0
- attune_llm/routing/model_router.py +362 -0
- attune_llm/security/IMPLEMENTATION_SUMMARY.md +413 -0
- attune_llm/security/PHASE2_COMPLETE.md +384 -0
- attune_llm/security/PHASE2_SECRETS_DETECTOR_COMPLETE.md +271 -0
- attune_llm/security/QUICK_REFERENCE.md +316 -0
- attune_llm/security/README.md +262 -0
- attune_llm/security/__init__.py +62 -0
- attune_llm/security/audit_logger.py +929 -0
- attune_llm/security/audit_logger_example.py +152 -0
- attune_llm/security/pii_scrubber.py +640 -0
- attune_llm/security/secrets_detector.py +678 -0
- attune_llm/security/secrets_detector_example.py +304 -0
- attune_llm/security/secure_memdocs.py +1192 -0
- attune_llm/security/secure_memdocs_example.py +278 -0
- attune_llm/session_status.py +745 -0
- attune_llm/state.py +246 -0
- attune_llm/utils/__init__.py +5 -0
- attune_llm/utils/tokens.py +349 -0
- attune_software/SOFTWARE_PLUGIN_README.md +57 -0
- attune_software/__init__.py +13 -0
- attune_software/cli/__init__.py +120 -0
- attune_software/cli/inspect.py +362 -0
- attune_software/cli.py +574 -0
- attune_software/plugin.py +188 -0
- workflow_scaffolding/__init__.py +11 -0
- workflow_scaffolding/__main__.py +12 -0
- workflow_scaffolding/cli.py +206 -0
- workflow_scaffolding/generator.py +265 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Test Lifecycle Manager - Event-Driven Test Management
|
|
2
|
+
|
|
3
|
+
Monitors file changes and automatically manages test lifecycle:
|
|
4
|
+
- Tracks when source files are created/modified/deleted
|
|
5
|
+
- Queues test generation tasks
|
|
6
|
+
- Schedules maintenance runs
|
|
7
|
+
- Integrates with git hooks and CI/CD
|
|
8
|
+
|
|
9
|
+
Can operate in different modes:
|
|
10
|
+
- watch: Monitor file changes in real-time
|
|
11
|
+
- hook: Process git hook events
|
|
12
|
+
- scheduled: Run periodic maintenance
|
|
13
|
+
- manual: User-triggered operations
|
|
14
|
+
|
|
15
|
+
Copyright 2025 Smart AI Memory, LLC
|
|
16
|
+
Licensed under Fair Source 0.9
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..project_index import ProjectIndex
|
|
29
|
+
from .test_maintenance import TestAction, TestMaintenanceWorkflow, TestPriority
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TestTask:
|
|
36
|
+
"""A queued test management task."""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
file_path: str
|
|
40
|
+
action: TestAction
|
|
41
|
+
priority: TestPriority
|
|
42
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
43
|
+
scheduled_for: datetime | None = None
|
|
44
|
+
status: str = "pending" # pending, running, completed, failed
|
|
45
|
+
result: dict[str, Any] | None = None
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"id": self.id,
|
|
50
|
+
"file_path": self.file_path,
|
|
51
|
+
"action": self.action.value,
|
|
52
|
+
"priority": self.priority.value,
|
|
53
|
+
"created_at": self.created_at.isoformat(),
|
|
54
|
+
"scheduled_for": self.scheduled_for.isoformat() if self.scheduled_for else None,
|
|
55
|
+
"status": self.status,
|
|
56
|
+
"result": self.result,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestLifecycleManager:
|
|
61
|
+
"""Manages the lifecycle of tests based on source file events.
|
|
62
|
+
|
|
63
|
+
Key responsibilities:
|
|
64
|
+
- Queue tasks when files change
|
|
65
|
+
- Process tasks based on priority
|
|
66
|
+
- Track task history
|
|
67
|
+
- Generate maintenance reports
|
|
68
|
+
- Integrate with CI/CD pipelines
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
project_root: str,
|
|
74
|
+
index: ProjectIndex | None = None,
|
|
75
|
+
auto_execute: bool = False,
|
|
76
|
+
queue_file: str | None = None,
|
|
77
|
+
):
|
|
78
|
+
self.project_root = Path(project_root)
|
|
79
|
+
self.index = index or ProjectIndex(str(project_root))
|
|
80
|
+
self.auto_execute = auto_execute
|
|
81
|
+
|
|
82
|
+
# Task queue
|
|
83
|
+
self._queue: list[TestTask] = []
|
|
84
|
+
self._history: list[TestTask] = []
|
|
85
|
+
self._task_counter = 0
|
|
86
|
+
|
|
87
|
+
# Queue persistence
|
|
88
|
+
self._queue_file = (
|
|
89
|
+
Path(queue_file) if queue_file else self.project_root / ".empathy" / "test_queue.json"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Callbacks
|
|
93
|
+
self._on_task_queued: list[Callable[[TestTask], None]] = []
|
|
94
|
+
self._on_task_completed: list[Callable[[TestTask], None]] = []
|
|
95
|
+
|
|
96
|
+
# Load existing queue
|
|
97
|
+
self._load_queue()
|
|
98
|
+
|
|
99
|
+
# ===== Event Handlers =====
|
|
100
|
+
|
|
101
|
+
async def on_file_created(self, file_path: str) -> TestTask | None:
|
|
102
|
+
"""Handle file creation."""
|
|
103
|
+
# Refresh index to include new file
|
|
104
|
+
self.index.refresh()
|
|
105
|
+
|
|
106
|
+
record = self.index.get_file(file_path)
|
|
107
|
+
if not record:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
if record.test_requirement.value != "required":
|
|
111
|
+
logger.debug(f"File {file_path} does not require tests")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Queue test creation
|
|
115
|
+
task = self._create_task(
|
|
116
|
+
file_path=file_path,
|
|
117
|
+
action=TestAction.CREATE,
|
|
118
|
+
priority=self._determine_priority(record),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logger.info(f"Queued test creation for new file: {file_path}")
|
|
122
|
+
return task
|
|
123
|
+
|
|
124
|
+
async def on_file_modified(self, file_path: str) -> TestTask | None:
|
|
125
|
+
"""Handle file modification."""
|
|
126
|
+
record = self.index.get_file(file_path)
|
|
127
|
+
if not record:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if record.test_requirement.value != "required":
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Update index
|
|
134
|
+
self.index.update_file(file_path, last_modified=datetime.now())
|
|
135
|
+
|
|
136
|
+
if record.tests_exist:
|
|
137
|
+
# Queue test review/update
|
|
138
|
+
task = self._create_task(
|
|
139
|
+
file_path=file_path,
|
|
140
|
+
action=TestAction.REVIEW,
|
|
141
|
+
priority=self._determine_priority(record),
|
|
142
|
+
)
|
|
143
|
+
logger.info(f"Queued test review for modified file: {file_path}")
|
|
144
|
+
else:
|
|
145
|
+
# Queue test creation
|
|
146
|
+
task = self._create_task(
|
|
147
|
+
file_path=file_path,
|
|
148
|
+
action=TestAction.CREATE,
|
|
149
|
+
priority=self._determine_priority(record),
|
|
150
|
+
)
|
|
151
|
+
logger.info(f"Queued test creation for modified file: {file_path}")
|
|
152
|
+
|
|
153
|
+
return task
|
|
154
|
+
|
|
155
|
+
async def on_file_deleted(self, file_path: str) -> TestTask | None:
|
|
156
|
+
"""Handle file deletion."""
|
|
157
|
+
record = self.index.get_file(file_path)
|
|
158
|
+
if not record or not record.test_file_path:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Queue orphan check
|
|
162
|
+
task = self._create_task(
|
|
163
|
+
file_path=file_path,
|
|
164
|
+
action=TestAction.DELETE,
|
|
165
|
+
priority=TestPriority.LOW,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
logger.info(f"Queued orphan test check for deleted file: {file_path}")
|
|
169
|
+
|
|
170
|
+
# Refresh index
|
|
171
|
+
self.index.refresh()
|
|
172
|
+
|
|
173
|
+
return task
|
|
174
|
+
|
|
175
|
+
async def on_files_changed(self, changed_files: list[str]) -> list[TestTask]:
|
|
176
|
+
"""Handle multiple file changes (e.g., from git hook)."""
|
|
177
|
+
tasks = []
|
|
178
|
+
|
|
179
|
+
for file_path in changed_files:
|
|
180
|
+
# Determine if file exists
|
|
181
|
+
full_path = self.project_root / file_path
|
|
182
|
+
if full_path.exists():
|
|
183
|
+
# Could be create or modify - check if in index
|
|
184
|
+
if self.index.get_file(file_path):
|
|
185
|
+
task = await self.on_file_modified(file_path)
|
|
186
|
+
else:
|
|
187
|
+
task = await self.on_file_created(file_path)
|
|
188
|
+
else:
|
|
189
|
+
task = await self.on_file_deleted(file_path)
|
|
190
|
+
|
|
191
|
+
if task:
|
|
192
|
+
tasks.append(task)
|
|
193
|
+
|
|
194
|
+
return tasks
|
|
195
|
+
|
|
196
|
+
# ===== Task Management =====
|
|
197
|
+
|
|
198
|
+
def _create_task(
|
|
199
|
+
self,
|
|
200
|
+
file_path: str,
|
|
201
|
+
action: TestAction,
|
|
202
|
+
priority: TestPriority,
|
|
203
|
+
) -> TestTask:
|
|
204
|
+
"""Create and queue a new task."""
|
|
205
|
+
self._task_counter += 1
|
|
206
|
+
|
|
207
|
+
task = TestTask(
|
|
208
|
+
id=f"task_{self._task_counter}_{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
|
209
|
+
file_path=file_path,
|
|
210
|
+
action=action,
|
|
211
|
+
priority=priority,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Check for duplicate
|
|
215
|
+
existing = self._find_pending_task(file_path, action)
|
|
216
|
+
if existing:
|
|
217
|
+
logger.debug(f"Task already queued for {file_path}")
|
|
218
|
+
return existing
|
|
219
|
+
|
|
220
|
+
self._queue.append(task)
|
|
221
|
+
self._save_queue()
|
|
222
|
+
|
|
223
|
+
# Notify callbacks
|
|
224
|
+
for callback in self._on_task_queued:
|
|
225
|
+
callback(task)
|
|
226
|
+
|
|
227
|
+
# Auto-execute if enabled
|
|
228
|
+
if self.auto_execute:
|
|
229
|
+
asyncio.create_task(self._execute_task(task))
|
|
230
|
+
|
|
231
|
+
return task
|
|
232
|
+
|
|
233
|
+
def _find_pending_task(self, file_path: str, action: TestAction) -> TestTask | None:
|
|
234
|
+
"""Find existing pending task for file."""
|
|
235
|
+
for task in self._queue:
|
|
236
|
+
if task.file_path == file_path and task.action == action and task.status == "pending":
|
|
237
|
+
return task
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def _determine_priority(self, record) -> TestPriority:
|
|
241
|
+
"""Determine task priority based on file impact."""
|
|
242
|
+
if record.impact_score >= 10.0:
|
|
243
|
+
return TestPriority.CRITICAL
|
|
244
|
+
if record.impact_score >= 5.0:
|
|
245
|
+
return TestPriority.HIGH
|
|
246
|
+
if record.impact_score >= 2.0:
|
|
247
|
+
return TestPriority.MEDIUM
|
|
248
|
+
return TestPriority.LOW
|
|
249
|
+
|
|
250
|
+
async def _execute_task(self, task: TestTask) -> bool:
|
|
251
|
+
"""Execute a single task."""
|
|
252
|
+
task.status = "running"
|
|
253
|
+
self._save_queue()
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
workflow = TestMaintenanceWorkflow(str(self.project_root), self.index)
|
|
257
|
+
|
|
258
|
+
# Create a mini-plan with just this task
|
|
259
|
+
result = await workflow.run(
|
|
260
|
+
{
|
|
261
|
+
"mode": "execute",
|
|
262
|
+
"changed_files": [task.file_path],
|
|
263
|
+
"max_items": 1,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
task.status = "completed"
|
|
268
|
+
task.result = result
|
|
269
|
+
|
|
270
|
+
# Move to history
|
|
271
|
+
self._queue.remove(task)
|
|
272
|
+
self._history.append(task)
|
|
273
|
+
|
|
274
|
+
# Notify callbacks
|
|
275
|
+
for callback in self._on_task_completed:
|
|
276
|
+
callback(task)
|
|
277
|
+
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"Task {task.id} failed: {e}")
|
|
282
|
+
task.status = "failed"
|
|
283
|
+
task.result = {"error": str(e)}
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
finally:
|
|
287
|
+
self._save_queue()
|
|
288
|
+
|
|
289
|
+
# ===== Queue Operations =====
|
|
290
|
+
|
|
291
|
+
def get_queue(self) -> list[dict[str, Any]]:
|
|
292
|
+
"""Get current task queue."""
|
|
293
|
+
return [task.to_dict() for task in self._queue]
|
|
294
|
+
|
|
295
|
+
def get_pending_count(self) -> int:
|
|
296
|
+
"""Get number of pending tasks."""
|
|
297
|
+
return len([t for t in self._queue if t.status == "pending"])
|
|
298
|
+
|
|
299
|
+
def get_queue_by_priority(self, priority: TestPriority) -> list[TestTask]:
|
|
300
|
+
"""Get tasks by priority."""
|
|
301
|
+
return [t for t in self._queue if t.priority == priority and t.status == "pending"]
|
|
302
|
+
|
|
303
|
+
def clear_queue(self) -> int:
|
|
304
|
+
"""Clear all pending tasks. Returns count of cleared tasks."""
|
|
305
|
+
count = len(self._queue)
|
|
306
|
+
self._queue.clear()
|
|
307
|
+
self._save_queue()
|
|
308
|
+
return count
|
|
309
|
+
|
|
310
|
+
async def process_queue(
|
|
311
|
+
self,
|
|
312
|
+
max_tasks: int = 10,
|
|
313
|
+
priority_filter: TestPriority | None = None,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
"""Process pending tasks in queue."""
|
|
316
|
+
tasks_to_process = [t for t in self._queue if t.status == "pending"]
|
|
317
|
+
|
|
318
|
+
if priority_filter:
|
|
319
|
+
priority_order = {
|
|
320
|
+
TestPriority.CRITICAL: 0,
|
|
321
|
+
TestPriority.HIGH: 1,
|
|
322
|
+
TestPriority.MEDIUM: 2,
|
|
323
|
+
TestPriority.LOW: 3,
|
|
324
|
+
TestPriority.DEFERRED: 4,
|
|
325
|
+
}
|
|
326
|
+
filter_level = priority_order[priority_filter]
|
|
327
|
+
tasks_to_process = [
|
|
328
|
+
t for t in tasks_to_process if priority_order[t.priority] <= filter_level
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
# Sort by priority
|
|
332
|
+
tasks_to_process.sort(
|
|
333
|
+
key=lambda t: (
|
|
334
|
+
{"critical": 0, "high": 1, "medium": 2, "low": 3, "deferred": 4}[t.priority.value],
|
|
335
|
+
t.created_at,
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Limit
|
|
340
|
+
tasks_to_process = tasks_to_process[:max_tasks]
|
|
341
|
+
|
|
342
|
+
# Use typed variables for proper type inference
|
|
343
|
+
processed = 0
|
|
344
|
+
succeeded = 0
|
|
345
|
+
failed = 0
|
|
346
|
+
details: list[dict] = []
|
|
347
|
+
|
|
348
|
+
for task in tasks_to_process:
|
|
349
|
+
processed += 1
|
|
350
|
+
success = await self._execute_task(task)
|
|
351
|
+
if success:
|
|
352
|
+
succeeded += 1
|
|
353
|
+
else:
|
|
354
|
+
failed += 1
|
|
355
|
+
details.append(task.to_dict())
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"processed": processed,
|
|
359
|
+
"succeeded": succeeded,
|
|
360
|
+
"failed": failed,
|
|
361
|
+
"details": details,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# ===== Persistence =====
|
|
365
|
+
|
|
366
|
+
def _save_queue(self) -> None:
|
|
367
|
+
"""Save queue to file."""
|
|
368
|
+
try:
|
|
369
|
+
self._queue_file.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
|
|
371
|
+
data = {
|
|
372
|
+
"queue": [t.to_dict() for t in self._queue],
|
|
373
|
+
"history": [t.to_dict() for t in self._history[-100:]], # Keep last 100
|
|
374
|
+
"counter": self._task_counter,
|
|
375
|
+
"saved_at": datetime.now().isoformat(),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
with open(self._queue_file, "w") as f:
|
|
379
|
+
json.dump(data, f, indent=2)
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.error(f"Failed to save queue: {e}")
|
|
383
|
+
|
|
384
|
+
def _load_queue(self) -> None:
|
|
385
|
+
"""Load queue from file."""
|
|
386
|
+
if not self._queue_file.exists():
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
with open(self._queue_file) as f:
|
|
391
|
+
data = json.load(f)
|
|
392
|
+
|
|
393
|
+
self._task_counter = data.get("counter", 0)
|
|
394
|
+
|
|
395
|
+
# Restore queue
|
|
396
|
+
for task_data in data.get("queue", []):
|
|
397
|
+
task = TestTask(
|
|
398
|
+
id=task_data["id"],
|
|
399
|
+
file_path=task_data["file_path"],
|
|
400
|
+
action=TestAction(task_data["action"]),
|
|
401
|
+
priority=TestPriority(task_data["priority"]),
|
|
402
|
+
created_at=datetime.fromisoformat(task_data["created_at"]),
|
|
403
|
+
status=task_data["status"],
|
|
404
|
+
)
|
|
405
|
+
self._queue.append(task)
|
|
406
|
+
|
|
407
|
+
logger.info(f"Loaded {len(self._queue)} tasks from queue")
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"Failed to load queue: {e}")
|
|
411
|
+
|
|
412
|
+
# ===== Scheduling =====
|
|
413
|
+
|
|
414
|
+
def schedule_maintenance(
|
|
415
|
+
self,
|
|
416
|
+
interval_hours: int = 24,
|
|
417
|
+
auto_execute: bool = False,
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
"""Schedule periodic maintenance runs."""
|
|
420
|
+
next_run = datetime.now() + timedelta(hours=interval_hours)
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
"scheduled": True,
|
|
424
|
+
"interval_hours": interval_hours,
|
|
425
|
+
"next_run": next_run.isoformat(),
|
|
426
|
+
"auto_execute": auto_execute,
|
|
427
|
+
"command": f"python -m attune.workflows.test_maintenance {'auto' if auto_execute else 'analyze'}",
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async def run_maintenance(self, auto_execute: bool = False) -> dict[str, Any]:
|
|
431
|
+
"""Run a full maintenance cycle."""
|
|
432
|
+
# Refresh index
|
|
433
|
+
self.index.refresh()
|
|
434
|
+
|
|
435
|
+
# Run workflow
|
|
436
|
+
workflow = TestMaintenanceWorkflow(str(self.project_root), self.index)
|
|
437
|
+
mode = "auto" if auto_execute else "analyze"
|
|
438
|
+
|
|
439
|
+
result = await workflow.run({"mode": mode})
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
"maintenance_run": True,
|
|
443
|
+
"timestamp": datetime.now().isoformat(),
|
|
444
|
+
"result": result,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
# ===== Git Hook Integration =====
|
|
448
|
+
|
|
449
|
+
async def process_git_pre_commit(self, staged_files: list[str]) -> dict[str, Any]:
|
|
450
|
+
"""Process git pre-commit hook.
|
|
451
|
+
|
|
452
|
+
Returns warnings about files being committed without tests.
|
|
453
|
+
"""
|
|
454
|
+
warnings = []
|
|
455
|
+
blocking = []
|
|
456
|
+
|
|
457
|
+
for file_path in staged_files:
|
|
458
|
+
record = self.index.get_file(file_path)
|
|
459
|
+
if not record:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
if record.test_requirement.value == "required" and not record.tests_exist:
|
|
463
|
+
if record.impact_score >= 5.0:
|
|
464
|
+
blocking.append(
|
|
465
|
+
{
|
|
466
|
+
"file": file_path,
|
|
467
|
+
"reason": f"High-impact file ({record.impact_score:.1f}) without tests",
|
|
468
|
+
},
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
warnings.append(
|
|
472
|
+
{
|
|
473
|
+
"file": file_path,
|
|
474
|
+
"reason": "File requires tests but none exist",
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
"hook": "pre-commit",
|
|
480
|
+
"staged_files": len(staged_files),
|
|
481
|
+
"blocking": blocking,
|
|
482
|
+
"warnings": warnings,
|
|
483
|
+
"allow_commit": len(blocking) == 0,
|
|
484
|
+
"message": (
|
|
485
|
+
f"Commit blocked: {len(blocking)} high-impact files need tests"
|
|
486
|
+
if blocking
|
|
487
|
+
else f"Commit allowed with {len(warnings)} test warnings"
|
|
488
|
+
),
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async def process_git_post_commit(self, changed_files: list[str]) -> dict[str, Any]:
|
|
492
|
+
"""Process git post-commit hook."""
|
|
493
|
+
tasks = await self.on_files_changed(changed_files)
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"hook": "post-commit",
|
|
497
|
+
"changed_files": len(changed_files),
|
|
498
|
+
"tasks_queued": len(tasks),
|
|
499
|
+
"tasks": [t.to_dict() for t in tasks],
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# ===== Callbacks =====
|
|
503
|
+
|
|
504
|
+
def on_task_queued(self, callback: Callable[[TestTask], None]) -> None:
|
|
505
|
+
"""Register callback for when task is queued."""
|
|
506
|
+
self._on_task_queued.append(callback)
|
|
507
|
+
|
|
508
|
+
def on_task_completed(self, callback: Callable[[TestTask], None]) -> None:
|
|
509
|
+
"""Register callback for when task completes."""
|
|
510
|
+
self._on_task_completed.append(callback)
|
|
511
|
+
|
|
512
|
+
# ===== Status =====
|
|
513
|
+
|
|
514
|
+
def get_status(self) -> dict[str, Any]:
|
|
515
|
+
"""Get lifecycle manager status."""
|
|
516
|
+
return {
|
|
517
|
+
"queue_size": len(self._queue),
|
|
518
|
+
"pending": len([t for t in self._queue if t.status == "pending"]),
|
|
519
|
+
"running": len([t for t in self._queue if t.status == "running"]),
|
|
520
|
+
"auto_execute": self.auto_execute,
|
|
521
|
+
"by_priority": {
|
|
522
|
+
priority.value: len([t for t in self._queue if t.priority == priority])
|
|
523
|
+
for priority in TestPriority
|
|
524
|
+
},
|
|
525
|
+
"history_size": len(self._history),
|
|
526
|
+
}
|