oscura 0.0.1__py3-none-any.whl → 0.1.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.
- oscura/__init__.py +813 -8
- oscura/__main__.py +392 -0
- oscura/analyzers/__init__.py +37 -0
- oscura/analyzers/digital/__init__.py +177 -0
- oscura/analyzers/digital/bus.py +691 -0
- oscura/analyzers/digital/clock.py +805 -0
- oscura/analyzers/digital/correlation.py +720 -0
- oscura/analyzers/digital/edges.py +632 -0
- oscura/analyzers/digital/extraction.py +413 -0
- oscura/analyzers/digital/quality.py +878 -0
- oscura/analyzers/digital/signal_quality.py +877 -0
- oscura/analyzers/digital/thresholds.py +708 -0
- oscura/analyzers/digital/timing.py +1104 -0
- oscura/analyzers/eye/__init__.py +46 -0
- oscura/analyzers/eye/diagram.py +434 -0
- oscura/analyzers/eye/metrics.py +555 -0
- oscura/analyzers/jitter/__init__.py +83 -0
- oscura/analyzers/jitter/ber.py +333 -0
- oscura/analyzers/jitter/decomposition.py +759 -0
- oscura/analyzers/jitter/measurements.py +413 -0
- oscura/analyzers/jitter/spectrum.py +220 -0
- oscura/analyzers/measurements.py +40 -0
- oscura/analyzers/packet/__init__.py +171 -0
- oscura/analyzers/packet/daq.py +1077 -0
- oscura/analyzers/packet/metrics.py +437 -0
- oscura/analyzers/packet/parser.py +327 -0
- oscura/analyzers/packet/payload.py +2156 -0
- oscura/analyzers/packet/payload_analysis.py +1312 -0
- oscura/analyzers/packet/payload_extraction.py +236 -0
- oscura/analyzers/packet/payload_patterns.py +670 -0
- oscura/analyzers/packet/stream.py +359 -0
- oscura/analyzers/patterns/__init__.py +266 -0
- oscura/analyzers/patterns/clustering.py +1036 -0
- oscura/analyzers/patterns/discovery.py +539 -0
- oscura/analyzers/patterns/learning.py +797 -0
- oscura/analyzers/patterns/matching.py +1091 -0
- oscura/analyzers/patterns/periodic.py +650 -0
- oscura/analyzers/patterns/sequences.py +767 -0
- oscura/analyzers/power/__init__.py +116 -0
- oscura/analyzers/power/ac_power.py +391 -0
- oscura/analyzers/power/basic.py +383 -0
- oscura/analyzers/power/conduction.py +314 -0
- oscura/analyzers/power/efficiency.py +297 -0
- oscura/analyzers/power/ripple.py +356 -0
- oscura/analyzers/power/soa.py +372 -0
- oscura/analyzers/power/switching.py +479 -0
- oscura/analyzers/protocol/__init__.py +150 -0
- oscura/analyzers/protocols/__init__.py +150 -0
- oscura/analyzers/protocols/base.py +500 -0
- oscura/analyzers/protocols/can.py +620 -0
- oscura/analyzers/protocols/can_fd.py +448 -0
- oscura/analyzers/protocols/flexray.py +405 -0
- oscura/analyzers/protocols/hdlc.py +399 -0
- oscura/analyzers/protocols/i2c.py +368 -0
- oscura/analyzers/protocols/i2s.py +296 -0
- oscura/analyzers/protocols/jtag.py +393 -0
- oscura/analyzers/protocols/lin.py +445 -0
- oscura/analyzers/protocols/manchester.py +333 -0
- oscura/analyzers/protocols/onewire.py +501 -0
- oscura/analyzers/protocols/spi.py +334 -0
- oscura/analyzers/protocols/swd.py +325 -0
- oscura/analyzers/protocols/uart.py +393 -0
- oscura/analyzers/protocols/usb.py +495 -0
- oscura/analyzers/signal_integrity/__init__.py +63 -0
- oscura/analyzers/signal_integrity/embedding.py +294 -0
- oscura/analyzers/signal_integrity/equalization.py +370 -0
- oscura/analyzers/signal_integrity/sparams.py +484 -0
- oscura/analyzers/spectral/__init__.py +53 -0
- oscura/analyzers/spectral/chunked.py +273 -0
- oscura/analyzers/spectral/chunked_fft.py +571 -0
- oscura/analyzers/spectral/chunked_wavelet.py +391 -0
- oscura/analyzers/spectral/fft.py +92 -0
- oscura/analyzers/statistical/__init__.py +250 -0
- oscura/analyzers/statistical/checksum.py +923 -0
- oscura/analyzers/statistical/chunked_corr.py +228 -0
- oscura/analyzers/statistical/classification.py +778 -0
- oscura/analyzers/statistical/entropy.py +1113 -0
- oscura/analyzers/statistical/ngrams.py +614 -0
- oscura/analyzers/statistics/__init__.py +119 -0
- oscura/analyzers/statistics/advanced.py +885 -0
- oscura/analyzers/statistics/basic.py +263 -0
- oscura/analyzers/statistics/correlation.py +630 -0
- oscura/analyzers/statistics/distribution.py +298 -0
- oscura/analyzers/statistics/outliers.py +463 -0
- oscura/analyzers/statistics/streaming.py +93 -0
- oscura/analyzers/statistics/trend.py +520 -0
- oscura/analyzers/validation.py +598 -0
- oscura/analyzers/waveform/__init__.py +36 -0
- oscura/analyzers/waveform/measurements.py +943 -0
- oscura/analyzers/waveform/measurements_with_uncertainty.py +371 -0
- oscura/analyzers/waveform/spectral.py +1689 -0
- oscura/analyzers/waveform/wavelets.py +298 -0
- oscura/api/__init__.py +62 -0
- oscura/api/dsl.py +538 -0
- oscura/api/fluent.py +571 -0
- oscura/api/operators.py +498 -0
- oscura/api/optimization.py +392 -0
- oscura/api/profiling.py +396 -0
- oscura/automotive/__init__.py +73 -0
- oscura/automotive/can/__init__.py +52 -0
- oscura/automotive/can/analysis.py +356 -0
- oscura/automotive/can/checksum.py +250 -0
- oscura/automotive/can/correlation.py +212 -0
- oscura/automotive/can/discovery.py +355 -0
- oscura/automotive/can/message_wrapper.py +375 -0
- oscura/automotive/can/models.py +385 -0
- oscura/automotive/can/patterns.py +381 -0
- oscura/automotive/can/session.py +452 -0
- oscura/automotive/can/state_machine.py +300 -0
- oscura/automotive/can/stimulus_response.py +461 -0
- oscura/automotive/dbc/__init__.py +15 -0
- oscura/automotive/dbc/generator.py +156 -0
- oscura/automotive/dbc/parser.py +146 -0
- oscura/automotive/dtc/__init__.py +30 -0
- oscura/automotive/dtc/database.py +3036 -0
- oscura/automotive/j1939/__init__.py +14 -0
- oscura/automotive/j1939/decoder.py +745 -0
- oscura/automotive/loaders/__init__.py +35 -0
- oscura/automotive/loaders/asc.py +98 -0
- oscura/automotive/loaders/blf.py +77 -0
- oscura/automotive/loaders/csv_can.py +136 -0
- oscura/automotive/loaders/dispatcher.py +136 -0
- oscura/automotive/loaders/mdf.py +331 -0
- oscura/automotive/loaders/pcap.py +132 -0
- oscura/automotive/obd/__init__.py +14 -0
- oscura/automotive/obd/decoder.py +707 -0
- oscura/automotive/uds/__init__.py +48 -0
- oscura/automotive/uds/decoder.py +265 -0
- oscura/automotive/uds/models.py +64 -0
- oscura/automotive/visualization.py +369 -0
- oscura/batch/__init__.py +55 -0
- oscura/batch/advanced.py +627 -0
- oscura/batch/aggregate.py +300 -0
- oscura/batch/analyze.py +139 -0
- oscura/batch/logging.py +487 -0
- oscura/batch/metrics.py +556 -0
- oscura/builders/__init__.py +41 -0
- oscura/builders/signal_builder.py +1131 -0
- oscura/cli/__init__.py +14 -0
- oscura/cli/batch.py +339 -0
- oscura/cli/characterize.py +273 -0
- oscura/cli/compare.py +775 -0
- oscura/cli/decode.py +551 -0
- oscura/cli/main.py +247 -0
- oscura/cli/shell.py +350 -0
- oscura/comparison/__init__.py +66 -0
- oscura/comparison/compare.py +397 -0
- oscura/comparison/golden.py +487 -0
- oscura/comparison/limits.py +391 -0
- oscura/comparison/mask.py +434 -0
- oscura/comparison/trace_diff.py +30 -0
- oscura/comparison/visualization.py +481 -0
- oscura/compliance/__init__.py +70 -0
- oscura/compliance/advanced.py +756 -0
- oscura/compliance/masks.py +363 -0
- oscura/compliance/reporting.py +483 -0
- oscura/compliance/testing.py +298 -0
- oscura/component/__init__.py +38 -0
- oscura/component/impedance.py +365 -0
- oscura/component/reactive.py +598 -0
- oscura/component/transmission_line.py +312 -0
- oscura/config/__init__.py +191 -0
- oscura/config/defaults.py +254 -0
- oscura/config/loader.py +348 -0
- oscura/config/memory.py +271 -0
- oscura/config/migration.py +458 -0
- oscura/config/pipeline.py +1077 -0
- oscura/config/preferences.py +530 -0
- oscura/config/protocol.py +875 -0
- oscura/config/schema.py +713 -0
- oscura/config/settings.py +420 -0
- oscura/config/thresholds.py +599 -0
- oscura/convenience.py +457 -0
- oscura/core/__init__.py +299 -0
- oscura/core/audit.py +457 -0
- oscura/core/backend_selector.py +405 -0
- oscura/core/cache.py +590 -0
- oscura/core/cancellation.py +439 -0
- oscura/core/confidence.py +225 -0
- oscura/core/config.py +506 -0
- oscura/core/correlation.py +216 -0
- oscura/core/cross_domain.py +422 -0
- oscura/core/debug.py +301 -0
- oscura/core/edge_cases.py +541 -0
- oscura/core/exceptions.py +535 -0
- oscura/core/gpu_backend.py +523 -0
- oscura/core/lazy.py +832 -0
- oscura/core/log_query.py +540 -0
- oscura/core/logging.py +931 -0
- oscura/core/logging_advanced.py +952 -0
- oscura/core/memoize.py +171 -0
- oscura/core/memory_check.py +274 -0
- oscura/core/memory_guard.py +290 -0
- oscura/core/memory_limits.py +336 -0
- oscura/core/memory_monitor.py +453 -0
- oscura/core/memory_progress.py +465 -0
- oscura/core/memory_warnings.py +315 -0
- oscura/core/numba_backend.py +362 -0
- oscura/core/performance.py +352 -0
- oscura/core/progress.py +524 -0
- oscura/core/provenance.py +358 -0
- oscura/core/results.py +331 -0
- oscura/core/types.py +504 -0
- oscura/core/uncertainty.py +383 -0
- oscura/discovery/__init__.py +52 -0
- oscura/discovery/anomaly_detector.py +672 -0
- oscura/discovery/auto_decoder.py +415 -0
- oscura/discovery/comparison.py +497 -0
- oscura/discovery/quality_validator.py +528 -0
- oscura/discovery/signal_detector.py +769 -0
- oscura/dsl/__init__.py +73 -0
- oscura/dsl/commands.py +246 -0
- oscura/dsl/interpreter.py +455 -0
- oscura/dsl/parser.py +689 -0
- oscura/dsl/repl.py +172 -0
- oscura/exceptions.py +59 -0
- oscura/exploratory/__init__.py +111 -0
- oscura/exploratory/error_recovery.py +642 -0
- oscura/exploratory/fuzzy.py +513 -0
- oscura/exploratory/fuzzy_advanced.py +786 -0
- oscura/exploratory/legacy.py +831 -0
- oscura/exploratory/parse.py +358 -0
- oscura/exploratory/recovery.py +275 -0
- oscura/exploratory/sync.py +382 -0
- oscura/exploratory/unknown.py +707 -0
- oscura/export/__init__.py +25 -0
- oscura/export/wireshark/README.md +265 -0
- oscura/export/wireshark/__init__.py +47 -0
- oscura/export/wireshark/generator.py +312 -0
- oscura/export/wireshark/lua_builder.py +159 -0
- oscura/export/wireshark/templates/dissector.lua.j2 +92 -0
- oscura/export/wireshark/type_mapping.py +165 -0
- oscura/export/wireshark/validator.py +105 -0
- oscura/exporters/__init__.py +94 -0
- oscura/exporters/csv.py +303 -0
- oscura/exporters/exporters.py +44 -0
- oscura/exporters/hdf5.py +219 -0
- oscura/exporters/html_export.py +701 -0
- oscura/exporters/json_export.py +291 -0
- oscura/exporters/markdown_export.py +367 -0
- oscura/exporters/matlab_export.py +354 -0
- oscura/exporters/npz_export.py +219 -0
- oscura/exporters/spice_export.py +210 -0
- oscura/extensibility/__init__.py +131 -0
- oscura/extensibility/docs.py +752 -0
- oscura/extensibility/extensions.py +1125 -0
- oscura/extensibility/logging.py +259 -0
- oscura/extensibility/measurements.py +485 -0
- oscura/extensibility/plugins.py +414 -0
- oscura/extensibility/registry.py +346 -0
- oscura/extensibility/templates.py +913 -0
- oscura/extensibility/validation.py +651 -0
- oscura/filtering/__init__.py +89 -0
- oscura/filtering/base.py +563 -0
- oscura/filtering/convenience.py +564 -0
- oscura/filtering/design.py +725 -0
- oscura/filtering/filters.py +32 -0
- oscura/filtering/introspection.py +605 -0
- oscura/guidance/__init__.py +24 -0
- oscura/guidance/recommender.py +429 -0
- oscura/guidance/wizard.py +518 -0
- oscura/inference/__init__.py +251 -0
- oscura/inference/active_learning/README.md +153 -0
- oscura/inference/active_learning/__init__.py +38 -0
- oscura/inference/active_learning/lstar.py +257 -0
- oscura/inference/active_learning/observation_table.py +230 -0
- oscura/inference/active_learning/oracle.py +78 -0
- oscura/inference/active_learning/teachers/__init__.py +15 -0
- oscura/inference/active_learning/teachers/simulator.py +192 -0
- oscura/inference/adaptive_tuning.py +453 -0
- oscura/inference/alignment.py +653 -0
- oscura/inference/bayesian.py +943 -0
- oscura/inference/binary.py +1016 -0
- oscura/inference/crc_reverse.py +711 -0
- oscura/inference/logic.py +288 -0
- oscura/inference/message_format.py +1305 -0
- oscura/inference/protocol.py +417 -0
- oscura/inference/protocol_dsl.py +1084 -0
- oscura/inference/protocol_library.py +1230 -0
- oscura/inference/sequences.py +809 -0
- oscura/inference/signal_intelligence.py +1509 -0
- oscura/inference/spectral.py +215 -0
- oscura/inference/state_machine.py +634 -0
- oscura/inference/stream.py +918 -0
- oscura/integrations/__init__.py +59 -0
- oscura/integrations/llm.py +1827 -0
- oscura/jupyter/__init__.py +32 -0
- oscura/jupyter/display.py +268 -0
- oscura/jupyter/magic.py +334 -0
- oscura/loaders/__init__.py +526 -0
- oscura/loaders/binary.py +69 -0
- oscura/loaders/configurable.py +1255 -0
- oscura/loaders/csv.py +26 -0
- oscura/loaders/csv_loader.py +473 -0
- oscura/loaders/hdf5.py +9 -0
- oscura/loaders/hdf5_loader.py +510 -0
- oscura/loaders/lazy.py +370 -0
- oscura/loaders/mmap_loader.py +583 -0
- oscura/loaders/numpy_loader.py +436 -0
- oscura/loaders/pcap.py +432 -0
- oscura/loaders/preprocessing.py +368 -0
- oscura/loaders/rigol.py +287 -0
- oscura/loaders/sigrok.py +321 -0
- oscura/loaders/tdms.py +367 -0
- oscura/loaders/tektronix.py +711 -0
- oscura/loaders/validation.py +584 -0
- oscura/loaders/vcd.py +464 -0
- oscura/loaders/wav.py +233 -0
- oscura/math/__init__.py +45 -0
- oscura/math/arithmetic.py +824 -0
- oscura/math/interpolation.py +413 -0
- oscura/onboarding/__init__.py +39 -0
- oscura/onboarding/help.py +498 -0
- oscura/onboarding/tutorials.py +405 -0
- oscura/onboarding/wizard.py +466 -0
- oscura/optimization/__init__.py +19 -0
- oscura/optimization/parallel.py +440 -0
- oscura/optimization/search.py +532 -0
- oscura/pipeline/__init__.py +43 -0
- oscura/pipeline/base.py +338 -0
- oscura/pipeline/composition.py +242 -0
- oscura/pipeline/parallel.py +448 -0
- oscura/pipeline/pipeline.py +375 -0
- oscura/pipeline/reverse_engineering.py +1119 -0
- oscura/plugins/__init__.py +122 -0
- oscura/plugins/base.py +272 -0
- oscura/plugins/cli.py +497 -0
- oscura/plugins/discovery.py +411 -0
- oscura/plugins/isolation.py +418 -0
- oscura/plugins/lifecycle.py +959 -0
- oscura/plugins/manager.py +493 -0
- oscura/plugins/registry.py +421 -0
- oscura/plugins/versioning.py +372 -0
- oscura/py.typed +0 -0
- oscura/quality/__init__.py +65 -0
- oscura/quality/ensemble.py +740 -0
- oscura/quality/explainer.py +338 -0
- oscura/quality/scoring.py +616 -0
- oscura/quality/warnings.py +456 -0
- oscura/reporting/__init__.py +248 -0
- oscura/reporting/advanced.py +1234 -0
- oscura/reporting/analyze.py +448 -0
- oscura/reporting/argument_preparer.py +596 -0
- oscura/reporting/auto_report.py +507 -0
- oscura/reporting/batch.py +615 -0
- oscura/reporting/chart_selection.py +223 -0
- oscura/reporting/comparison.py +330 -0
- oscura/reporting/config.py +615 -0
- oscura/reporting/content/__init__.py +39 -0
- oscura/reporting/content/executive.py +127 -0
- oscura/reporting/content/filtering.py +191 -0
- oscura/reporting/content/minimal.py +257 -0
- oscura/reporting/content/verbosity.py +162 -0
- oscura/reporting/core.py +508 -0
- oscura/reporting/core_formats/__init__.py +17 -0
- oscura/reporting/core_formats/multi_format.py +210 -0
- oscura/reporting/engine.py +836 -0
- oscura/reporting/export.py +366 -0
- oscura/reporting/formatting/__init__.py +129 -0
- oscura/reporting/formatting/emphasis.py +81 -0
- oscura/reporting/formatting/numbers.py +403 -0
- oscura/reporting/formatting/standards.py +55 -0
- oscura/reporting/formatting.py +466 -0
- oscura/reporting/html.py +578 -0
- oscura/reporting/index.py +590 -0
- oscura/reporting/multichannel.py +296 -0
- oscura/reporting/output.py +379 -0
- oscura/reporting/pdf.py +373 -0
- oscura/reporting/plots.py +731 -0
- oscura/reporting/pptx_export.py +360 -0
- oscura/reporting/renderers/__init__.py +11 -0
- oscura/reporting/renderers/pdf.py +94 -0
- oscura/reporting/sections.py +471 -0
- oscura/reporting/standards.py +680 -0
- oscura/reporting/summary_generator.py +368 -0
- oscura/reporting/tables.py +397 -0
- oscura/reporting/template_system.py +724 -0
- oscura/reporting/templates/__init__.py +15 -0
- oscura/reporting/templates/definition.py +205 -0
- oscura/reporting/templates/index.html +649 -0
- oscura/reporting/templates/index.md +173 -0
- oscura/schemas/__init__.py +158 -0
- oscura/schemas/bus_configuration.json +322 -0
- oscura/schemas/device_mapping.json +182 -0
- oscura/schemas/packet_format.json +418 -0
- oscura/schemas/protocol_definition.json +363 -0
- oscura/search/__init__.py +16 -0
- oscura/search/anomaly.py +292 -0
- oscura/search/context.py +149 -0
- oscura/search/pattern.py +160 -0
- oscura/session/__init__.py +34 -0
- oscura/session/annotations.py +289 -0
- oscura/session/history.py +313 -0
- oscura/session/session.py +445 -0
- oscura/streaming/__init__.py +43 -0
- oscura/streaming/chunked.py +611 -0
- oscura/streaming/progressive.py +393 -0
- oscura/streaming/realtime.py +622 -0
- oscura/testing/__init__.py +54 -0
- oscura/testing/synthetic.py +808 -0
- oscura/triggering/__init__.py +68 -0
- oscura/triggering/base.py +229 -0
- oscura/triggering/edge.py +353 -0
- oscura/triggering/pattern.py +344 -0
- oscura/triggering/pulse.py +581 -0
- oscura/triggering/window.py +453 -0
- oscura/ui/__init__.py +48 -0
- oscura/ui/formatters.py +526 -0
- oscura/ui/progressive_display.py +340 -0
- oscura/utils/__init__.py +99 -0
- oscura/utils/autodetect.py +338 -0
- oscura/utils/buffer.py +389 -0
- oscura/utils/lazy.py +407 -0
- oscura/utils/lazy_imports.py +147 -0
- oscura/utils/memory.py +836 -0
- oscura/utils/memory_advanced.py +1326 -0
- oscura/utils/memory_extensions.py +465 -0
- oscura/utils/progressive.py +352 -0
- oscura/utils/windowing.py +362 -0
- oscura/visualization/__init__.py +321 -0
- oscura/visualization/accessibility.py +526 -0
- oscura/visualization/annotations.py +374 -0
- oscura/visualization/axis_scaling.py +305 -0
- oscura/visualization/colors.py +453 -0
- oscura/visualization/digital.py +337 -0
- oscura/visualization/eye.py +420 -0
- oscura/visualization/histogram.py +281 -0
- oscura/visualization/interactive.py +858 -0
- oscura/visualization/jitter.py +702 -0
- oscura/visualization/keyboard.py +394 -0
- oscura/visualization/layout.py +365 -0
- oscura/visualization/optimization.py +1028 -0
- oscura/visualization/palettes.py +446 -0
- oscura/visualization/plot.py +92 -0
- oscura/visualization/power.py +290 -0
- oscura/visualization/power_extended.py +626 -0
- oscura/visualization/presets.py +467 -0
- oscura/visualization/protocols.py +932 -0
- oscura/visualization/render.py +207 -0
- oscura/visualization/rendering.py +444 -0
- oscura/visualization/reverse_engineering.py +791 -0
- oscura/visualization/signal_integrity.py +808 -0
- oscura/visualization/specialized.py +553 -0
- oscura/visualization/spectral.py +811 -0
- oscura/visualization/styles.py +381 -0
- oscura/visualization/thumbnails.py +311 -0
- oscura/visualization/time_axis.py +351 -0
- oscura/visualization/waveform.py +367 -0
- oscura/workflow/__init__.py +13 -0
- oscura/workflow/dag.py +377 -0
- oscura/workflows/__init__.py +58 -0
- oscura/workflows/compliance.py +280 -0
- oscura/workflows/digital.py +272 -0
- oscura/workflows/multi_trace.py +502 -0
- oscura/workflows/power.py +178 -0
- oscura/workflows/protocol.py +492 -0
- oscura/workflows/reverse_engineering.py +639 -0
- oscura/workflows/signal_integrity.py +227 -0
- oscura-0.1.0.dist-info/METADATA +300 -0
- oscura-0.1.0.dist-info/RECORD +463 -0
- oscura-0.1.0.dist-info/entry_points.txt +2 -0
- {oscura-0.0.1.dist-info → oscura-0.1.0.dist-info}/licenses/LICENSE +1 -1
- oscura-0.0.1.dist-info/METADATA +0 -63
- oscura-0.0.1.dist-info/RECORD +0 -5
- {oscura-0.0.1.dist-info → oscura-0.1.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
"""Plugin lifecycle management and dependency resolution.
|
|
2
|
+
|
|
3
|
+
This module provides advanced plugin lifecycle management including
|
|
4
|
+
dependency resolution, graceful enable/disable, lazy loading, and
|
|
5
|
+
hot reload capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum, auto
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from oscura.plugins.base import PluginBase, PluginMetadata
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PluginState(Enum):
|
|
29
|
+
"""Plugin lifecycle states."""
|
|
30
|
+
|
|
31
|
+
DISCOVERED = auto() # Found but not loaded
|
|
32
|
+
LOADING = auto() # Currently loading
|
|
33
|
+
LOADED = auto() # Loaded but not configured
|
|
34
|
+
CONFIGURED = auto() # Configured and ready
|
|
35
|
+
ENABLED = auto() # Fully enabled
|
|
36
|
+
DISABLED = auto() # Disabled by user
|
|
37
|
+
ERROR = auto() # Load/configure error
|
|
38
|
+
UNLOADING = auto() # Currently unloading
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PluginLoadError:
|
|
43
|
+
"""Plugin load error details.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
plugin_name: Name of plugin that failed
|
|
47
|
+
error: Exception that occurred
|
|
48
|
+
traceback: Traceback string
|
|
49
|
+
stage: Stage where failure occurred
|
|
50
|
+
recoverable: Whether error is recoverable
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
plugin_name: str
|
|
54
|
+
error: Exception
|
|
55
|
+
traceback: str = ""
|
|
56
|
+
stage: str = "load" # discovery, load, configure, enable
|
|
57
|
+
recoverable: bool = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DependencyInfo:
|
|
62
|
+
"""Plugin dependency information.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
name: Dependency plugin name
|
|
66
|
+
version_spec: Version specification (semver)
|
|
67
|
+
optional: Whether dependency is optional
|
|
68
|
+
resolved: Whether dependency has been resolved
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
version_spec: str = "*"
|
|
73
|
+
optional: bool = False
|
|
74
|
+
resolved: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PluginHandle:
|
|
79
|
+
"""Handle for managing a plugin instance.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
metadata: Plugin metadata
|
|
83
|
+
instance: Plugin instance (None if not loaded)
|
|
84
|
+
state: Current lifecycle state
|
|
85
|
+
dependencies: Plugin dependencies
|
|
86
|
+
dependents: Plugins that depend on this one
|
|
87
|
+
errors: List of errors encountered
|
|
88
|
+
load_time: Time taken to load (seconds)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
metadata: PluginMetadata
|
|
92
|
+
instance: PluginBase | None = None
|
|
93
|
+
state: PluginState = PluginState.DISCOVERED
|
|
94
|
+
dependencies: list[DependencyInfo] = field(default_factory=list)
|
|
95
|
+
dependents: list[str] = field(default_factory=list)
|
|
96
|
+
errors: list[PluginLoadError] = field(default_factory=list)
|
|
97
|
+
load_time: float = 0.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DependencyGraph:
|
|
101
|
+
"""Dependency resolution graph for plugins.
|
|
102
|
+
|
|
103
|
+
Resolves plugin dependencies using topological sort to ensure
|
|
104
|
+
correct load order and detect cycles.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> graph = DependencyGraph()
|
|
108
|
+
>>> graph.add_plugin("core")
|
|
109
|
+
>>> graph.add_dependency("decoder", "core", ">=1.0.0")
|
|
110
|
+
>>> order = graph.resolve_order()
|
|
111
|
+
>>> print(order) # ['core', 'decoder']
|
|
112
|
+
|
|
113
|
+
References:
|
|
114
|
+
PLUG-005: Dependency Resolution
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self) -> None:
|
|
118
|
+
"""Initialize empty dependency graph."""
|
|
119
|
+
self._nodes: dict[str, list[DependencyInfo]] = {}
|
|
120
|
+
self._in_degree: dict[str, int] = {}
|
|
121
|
+
# Reverse adjacency: maps dependency -> list of dependents
|
|
122
|
+
self._reverse_adj: dict[str, list[str]] = {}
|
|
123
|
+
|
|
124
|
+
def add_plugin(self, name: str) -> None:
|
|
125
|
+
"""Add plugin node to graph.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: Plugin name
|
|
129
|
+
"""
|
|
130
|
+
if name not in self._nodes:
|
|
131
|
+
self._nodes[name] = []
|
|
132
|
+
self._in_degree[name] = 0
|
|
133
|
+
self._reverse_adj[name] = []
|
|
134
|
+
|
|
135
|
+
def add_dependency(
|
|
136
|
+
self,
|
|
137
|
+
plugin: str,
|
|
138
|
+
depends_on: str,
|
|
139
|
+
version_spec: str = "*",
|
|
140
|
+
optional: bool = False,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Add dependency edge.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
plugin: Plugin that has the dependency
|
|
146
|
+
depends_on: Plugin being depended on
|
|
147
|
+
version_spec: Version specification
|
|
148
|
+
optional: Whether dependency is optional
|
|
149
|
+
"""
|
|
150
|
+
self.add_plugin(plugin)
|
|
151
|
+
self.add_plugin(depends_on)
|
|
152
|
+
|
|
153
|
+
dep = DependencyInfo(name=depends_on, version_spec=version_spec, optional=optional)
|
|
154
|
+
self._nodes[plugin].append(dep)
|
|
155
|
+
self._in_degree[plugin] += 1
|
|
156
|
+
# Track reverse edge: depends_on -> plugin (plugin depends on depends_on)
|
|
157
|
+
self._reverse_adj[depends_on].append(plugin)
|
|
158
|
+
|
|
159
|
+
def resolve_order(self) -> list[str]:
|
|
160
|
+
"""Resolve topological order for loading.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of plugin names in load order
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: If circular dependency detected
|
|
167
|
+
|
|
168
|
+
References:
|
|
169
|
+
PLUG-005: Dependency Resolution - circular dependency detection
|
|
170
|
+
"""
|
|
171
|
+
# Kahn's algorithm
|
|
172
|
+
in_degree = dict(self._in_degree)
|
|
173
|
+
queue = [n for n, d in in_degree.items() if d == 0]
|
|
174
|
+
result = []
|
|
175
|
+
|
|
176
|
+
while queue:
|
|
177
|
+
node = queue.pop(0)
|
|
178
|
+
result.append(node)
|
|
179
|
+
|
|
180
|
+
# Decrement in_degree for nodes that depend on this one
|
|
181
|
+
for dependent in self._reverse_adj.get(node, []):
|
|
182
|
+
in_degree[dependent] -= 1
|
|
183
|
+
if in_degree[dependent] == 0:
|
|
184
|
+
queue.append(dependent)
|
|
185
|
+
|
|
186
|
+
if len(result) != len(self._nodes):
|
|
187
|
+
# Cycle detected - find the cycle
|
|
188
|
+
remaining = set(self._nodes.keys()) - set(result)
|
|
189
|
+
cycle = self._find_cycle(remaining)
|
|
190
|
+
|
|
191
|
+
raise ValueError(f"Circular dependency detected: {' -> '.join([*cycle, cycle[0]])}")
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def _find_cycle(self, nodes: set[str]) -> list[str]:
|
|
196
|
+
"""Find a cycle in the dependency graph.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
nodes: Set of nodes that may be in a cycle
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of nodes forming a cycle
|
|
203
|
+
|
|
204
|
+
References:
|
|
205
|
+
PLUG-005: Dependency Resolution - circular dependency detection
|
|
206
|
+
"""
|
|
207
|
+
visited: set[str] = set()
|
|
208
|
+
rec_stack: list[str] = []
|
|
209
|
+
|
|
210
|
+
def dfs(node: str) -> list[str] | None:
|
|
211
|
+
visited.add(node)
|
|
212
|
+
rec_stack.append(node)
|
|
213
|
+
|
|
214
|
+
for dep in self._nodes.get(node, []):
|
|
215
|
+
if dep.name not in nodes:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if dep.name not in visited:
|
|
219
|
+
cycle = dfs(dep.name)
|
|
220
|
+
if cycle:
|
|
221
|
+
return cycle
|
|
222
|
+
elif dep.name in rec_stack:
|
|
223
|
+
# Found cycle
|
|
224
|
+
idx = rec_stack.index(dep.name)
|
|
225
|
+
return rec_stack[idx:]
|
|
226
|
+
|
|
227
|
+
rec_stack.pop()
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
for node in nodes:
|
|
231
|
+
if node not in visited:
|
|
232
|
+
cycle = dfs(node)
|
|
233
|
+
if cycle:
|
|
234
|
+
return cycle
|
|
235
|
+
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
def get_dependencies(self, plugin: str) -> list[DependencyInfo]:
|
|
239
|
+
"""Get dependencies for a plugin.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
plugin: Plugin name
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of dependencies
|
|
246
|
+
"""
|
|
247
|
+
return self._nodes.get(plugin, [])
|
|
248
|
+
|
|
249
|
+
def get_dependents(self, plugin: str) -> list[str]:
|
|
250
|
+
"""Get plugins that depend on given plugin.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
plugin: Plugin name
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of dependent plugin names
|
|
257
|
+
"""
|
|
258
|
+
dependents = []
|
|
259
|
+
for name, deps in self._nodes.items():
|
|
260
|
+
if any(d.name == plugin for d in deps):
|
|
261
|
+
dependents.append(name)
|
|
262
|
+
return dependents
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class PluginLifecycleManager:
|
|
266
|
+
"""Manager for plugin lifecycle operations.
|
|
267
|
+
|
|
268
|
+
Handles plugin loading, configuration, enabling/disabling,
|
|
269
|
+
hot reload, and graceful degradation.
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
>>> manager = PluginLifecycleManager()
|
|
273
|
+
>>> manager.discover_plugins()
|
|
274
|
+
>>> manager.load_plugin("uart_decoder")
|
|
275
|
+
>>> manager.enable_plugin("uart_decoder")
|
|
276
|
+
|
|
277
|
+
References:
|
|
278
|
+
PLUG-004: Plugin Lifecycle (enable/disable/reload)
|
|
279
|
+
PLUG-005: Dependency Resolution
|
|
280
|
+
PLUG-006: Graceful Degradation
|
|
281
|
+
PLUG-007: Lazy Loading
|
|
282
|
+
PLUG-008: Plugin Hot Reload
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, plugin_dirs: list[Path] | None = None) -> None:
|
|
286
|
+
"""Initialize lifecycle manager.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
plugin_dirs: Directories to search for plugins
|
|
290
|
+
"""
|
|
291
|
+
self._plugin_dirs = plugin_dirs or []
|
|
292
|
+
self._handles: dict[str, PluginHandle] = {}
|
|
293
|
+
self._dependency_graph = DependencyGraph()
|
|
294
|
+
self._lock = threading.RLock()
|
|
295
|
+
self._lazy_loaders: dict[str, Callable[[], PluginBase]] = {}
|
|
296
|
+
self._file_watchers: dict[str, float] = {} # path -> mtime
|
|
297
|
+
self._lifecycle_callbacks: list[Callable[[str, PluginState], None]] = []
|
|
298
|
+
|
|
299
|
+
def discover_plugins(self) -> list[str]:
|
|
300
|
+
"""Discover available plugins.
|
|
301
|
+
|
|
302
|
+
Scans plugin directories for plugin manifests and Python files.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
List of discovered plugin names
|
|
306
|
+
|
|
307
|
+
References:
|
|
308
|
+
PLUG-007: Lazy Loading
|
|
309
|
+
"""
|
|
310
|
+
discovered = []
|
|
311
|
+
|
|
312
|
+
for plugin_dir in self._plugin_dirs:
|
|
313
|
+
if not plugin_dir.exists():
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
for item in plugin_dir.iterdir():
|
|
317
|
+
if item.is_dir() and (item / "__init__.py").exists():
|
|
318
|
+
# Package plugin
|
|
319
|
+
name = item.name
|
|
320
|
+
self._register_lazy_loader(name, item)
|
|
321
|
+
discovered.append(name)
|
|
322
|
+
elif item.suffix == ".py" and not item.name.startswith("_"):
|
|
323
|
+
# Single file plugin
|
|
324
|
+
name = item.stem
|
|
325
|
+
self._register_lazy_loader(name, item)
|
|
326
|
+
discovered.append(name)
|
|
327
|
+
|
|
328
|
+
logger.info(f"Discovered {len(discovered)} plugins")
|
|
329
|
+
return discovered
|
|
330
|
+
|
|
331
|
+
def _register_lazy_loader(self, name: str, path: Path) -> None:
|
|
332
|
+
"""Register lazy loader for a plugin.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
name: Plugin name
|
|
336
|
+
path: Path to plugin
|
|
337
|
+
|
|
338
|
+
References:
|
|
339
|
+
PLUG-007: Lazy Loading
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def loader() -> PluginBase:
|
|
343
|
+
return self._load_plugin_from_path(name, path)
|
|
344
|
+
|
|
345
|
+
self._lazy_loaders[name] = loader
|
|
346
|
+
|
|
347
|
+
# Create handle in DISCOVERED state
|
|
348
|
+
handle = PluginHandle(
|
|
349
|
+
metadata=PluginMetadata(name=name, version="0.0.0"),
|
|
350
|
+
state=PluginState.DISCOVERED,
|
|
351
|
+
)
|
|
352
|
+
self._handles[name] = handle
|
|
353
|
+
|
|
354
|
+
# Track file for hot reload
|
|
355
|
+
if path.is_file():
|
|
356
|
+
self._file_watchers[str(path)] = path.stat().st_mtime
|
|
357
|
+
else:
|
|
358
|
+
init_path = path / "__init__.py"
|
|
359
|
+
if init_path.exists():
|
|
360
|
+
self._file_watchers[str(init_path)] = init_path.stat().st_mtime
|
|
361
|
+
|
|
362
|
+
def _load_plugin_from_path(self, name: str, path: Path) -> PluginBase:
|
|
363
|
+
"""Load plugin from path.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
name: Plugin name
|
|
367
|
+
path: Path to plugin
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Loaded plugin instance
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ImportError: If plugin cannot be loaded or no PluginBase subclass found
|
|
374
|
+
"""
|
|
375
|
+
if path.is_dir():
|
|
376
|
+
spec = importlib.util.spec_from_file_location(name, path / "__init__.py")
|
|
377
|
+
else:
|
|
378
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
379
|
+
|
|
380
|
+
if spec is None or spec.loader is None:
|
|
381
|
+
raise ImportError(f"Cannot load plugin from {path}")
|
|
382
|
+
|
|
383
|
+
module = importlib.util.module_from_spec(spec)
|
|
384
|
+
sys.modules[name] = module
|
|
385
|
+
spec.loader.exec_module(module)
|
|
386
|
+
|
|
387
|
+
# Find PluginBase subclass
|
|
388
|
+
for attr_name in dir(module):
|
|
389
|
+
attr = getattr(module, attr_name)
|
|
390
|
+
if isinstance(attr, type) and issubclass(attr, PluginBase) and attr is not PluginBase:
|
|
391
|
+
return attr()
|
|
392
|
+
|
|
393
|
+
raise ImportError(f"No PluginBase subclass found in {path}")
|
|
394
|
+
|
|
395
|
+
def load_plugin(
|
|
396
|
+
self, name: str, *, lazy: bool = True, resolve_deps: bool = True
|
|
397
|
+
) -> PluginHandle:
|
|
398
|
+
"""Load a plugin.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
name: Plugin name
|
|
402
|
+
lazy: Use lazy loading if available
|
|
403
|
+
resolve_deps: Resolve dependencies first
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Plugin handle
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
Exception: If plugin loading or initialization fails.
|
|
410
|
+
ValueError: If plugin not found or dependency resolution fails
|
|
411
|
+
|
|
412
|
+
References:
|
|
413
|
+
PLUG-004: Plugin Lifecycle
|
|
414
|
+
PLUG-005: Dependency Resolution
|
|
415
|
+
PLUG-007: Lazy Loading
|
|
416
|
+
"""
|
|
417
|
+
with self._lock:
|
|
418
|
+
if name not in self._handles:
|
|
419
|
+
raise ValueError(f"Plugin '{name}' not discovered")
|
|
420
|
+
|
|
421
|
+
handle = self._handles[name]
|
|
422
|
+
|
|
423
|
+
if handle.state == PluginState.LOADED:
|
|
424
|
+
return handle
|
|
425
|
+
|
|
426
|
+
# Resolve dependencies first
|
|
427
|
+
if resolve_deps:
|
|
428
|
+
self._resolve_dependencies(name)
|
|
429
|
+
|
|
430
|
+
handle.state = PluginState.LOADING
|
|
431
|
+
self._notify_state_change(name, handle.state)
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
import time
|
|
435
|
+
|
|
436
|
+
start = time.time()
|
|
437
|
+
|
|
438
|
+
# Use lazy loader if available
|
|
439
|
+
if lazy and name in self._lazy_loaders:
|
|
440
|
+
instance = self._lazy_loaders[name]()
|
|
441
|
+
else:
|
|
442
|
+
instance = self._load_plugin_from_path(name, self._get_plugin_path(name))
|
|
443
|
+
|
|
444
|
+
handle.instance = instance
|
|
445
|
+
handle.metadata = instance.metadata
|
|
446
|
+
handle.load_time = time.time() - start
|
|
447
|
+
|
|
448
|
+
# Call on_load
|
|
449
|
+
instance.on_load()
|
|
450
|
+
|
|
451
|
+
handle.state = PluginState.LOADED
|
|
452
|
+
self._notify_state_change(name, handle.state)
|
|
453
|
+
|
|
454
|
+
logger.info(
|
|
455
|
+
f"Loaded plugin '{name}' v{handle.metadata.version} in {handle.load_time:.3f}s"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return handle
|
|
459
|
+
|
|
460
|
+
except Exception as e:
|
|
461
|
+
import traceback
|
|
462
|
+
|
|
463
|
+
error = PluginLoadError(
|
|
464
|
+
plugin_name=name,
|
|
465
|
+
error=e,
|
|
466
|
+
traceback=traceback.format_exc(),
|
|
467
|
+
stage="load",
|
|
468
|
+
recoverable=True,
|
|
469
|
+
)
|
|
470
|
+
handle.errors.append(error)
|
|
471
|
+
handle.state = PluginState.ERROR
|
|
472
|
+
self._notify_state_change(name, handle.state)
|
|
473
|
+
|
|
474
|
+
logger.error(f"Failed to load plugin '{name}': {e}")
|
|
475
|
+
raise
|
|
476
|
+
|
|
477
|
+
def _resolve_dependencies(self, name: str) -> None:
|
|
478
|
+
"""Resolve and load dependencies.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
name: Plugin name
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
ValueError: If required dependency not found
|
|
485
|
+
|
|
486
|
+
References:
|
|
487
|
+
PLUG-005: Dependency Resolution
|
|
488
|
+
"""
|
|
489
|
+
handle = self._handles[name]
|
|
490
|
+
|
|
491
|
+
for dep in handle.dependencies:
|
|
492
|
+
if dep.resolved:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
if dep.name not in self._handles:
|
|
496
|
+
if dep.optional:
|
|
497
|
+
logger.warning(f"Optional dependency '{dep.name}' for '{name}' not found")
|
|
498
|
+
continue
|
|
499
|
+
else:
|
|
500
|
+
raise ValueError(f"Required dependency '{dep.name}' for '{name}' not found")
|
|
501
|
+
|
|
502
|
+
dep_handle = self._handles[dep.name]
|
|
503
|
+
if dep_handle.state not in (PluginState.LOADED, PluginState.ENABLED):
|
|
504
|
+
self.load_plugin(dep.name)
|
|
505
|
+
|
|
506
|
+
dep.resolved = True
|
|
507
|
+
|
|
508
|
+
def configure_plugin(self, name: str, config: dict[str, Any]) -> PluginHandle:
|
|
509
|
+
"""Configure a loaded plugin.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
name: Plugin name
|
|
513
|
+
config: Configuration dictionary
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Updated plugin handle
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
ValueError: If plugin not found or in invalid state
|
|
520
|
+
Exception: If configuration fails
|
|
521
|
+
|
|
522
|
+
References:
|
|
523
|
+
PLUG-004: Plugin Lifecycle
|
|
524
|
+
"""
|
|
525
|
+
with self._lock:
|
|
526
|
+
handle = self._handles.get(name)
|
|
527
|
+
if handle is None:
|
|
528
|
+
raise ValueError(f"Plugin '{name}' not found")
|
|
529
|
+
|
|
530
|
+
if handle.state not in (PluginState.LOADED, PluginState.CONFIGURED):
|
|
531
|
+
raise ValueError(f"Cannot configure plugin in state {handle.state}")
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
if handle.instance:
|
|
535
|
+
handle.instance.on_configure(config)
|
|
536
|
+
handle.state = PluginState.CONFIGURED
|
|
537
|
+
self._notify_state_change(name, handle.state)
|
|
538
|
+
logger.info(f"Configured plugin '{name}'")
|
|
539
|
+
return handle
|
|
540
|
+
|
|
541
|
+
except Exception as e:
|
|
542
|
+
import traceback
|
|
543
|
+
|
|
544
|
+
error = PluginLoadError(
|
|
545
|
+
plugin_name=name,
|
|
546
|
+
error=e,
|
|
547
|
+
traceback=traceback.format_exc(),
|
|
548
|
+
stage="configure",
|
|
549
|
+
)
|
|
550
|
+
handle.errors.append(error)
|
|
551
|
+
handle.state = PluginState.ERROR
|
|
552
|
+
self._notify_state_change(name, handle.state)
|
|
553
|
+
raise
|
|
554
|
+
|
|
555
|
+
def enable_plugin(self, name: str) -> PluginHandle:
|
|
556
|
+
"""Enable a configured plugin.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
name: Plugin name
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Updated plugin handle
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ValueError: If plugin not found
|
|
566
|
+
|
|
567
|
+
References:
|
|
568
|
+
PLUG-002: Plugin Registration - lifecycle hooks
|
|
569
|
+
PLUG-004: Plugin Lifecycle
|
|
570
|
+
"""
|
|
571
|
+
with self._lock:
|
|
572
|
+
handle = self._handles.get(name)
|
|
573
|
+
if handle is None:
|
|
574
|
+
raise ValueError(f"Plugin '{name}' not found")
|
|
575
|
+
|
|
576
|
+
if handle.state == PluginState.ENABLED:
|
|
577
|
+
return handle
|
|
578
|
+
|
|
579
|
+
if handle.state == PluginState.DISCOVERED:
|
|
580
|
+
self.load_plugin(name)
|
|
581
|
+
handle = self._handles[name]
|
|
582
|
+
|
|
583
|
+
if handle.state == PluginState.LOADED:
|
|
584
|
+
self.configure_plugin(name, {})
|
|
585
|
+
handle = self._handles[name]
|
|
586
|
+
|
|
587
|
+
# Call on_enable hook
|
|
588
|
+
if handle.instance:
|
|
589
|
+
handle.instance.on_enable()
|
|
590
|
+
|
|
591
|
+
handle.state = PluginState.ENABLED
|
|
592
|
+
self._notify_state_change(name, handle.state)
|
|
593
|
+
logger.info(f"Enabled plugin '{name}'")
|
|
594
|
+
return handle
|
|
595
|
+
|
|
596
|
+
def disable_plugin(self, name: str, force: bool = False) -> PluginHandle:
|
|
597
|
+
"""Disable a plugin.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
name: Plugin name
|
|
601
|
+
force: Force disable even if dependents exist
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
Updated plugin handle
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
ValueError: If dependents exist and force=False
|
|
608
|
+
|
|
609
|
+
References:
|
|
610
|
+
PLUG-002: Plugin Registration - lifecycle hooks
|
|
611
|
+
PLUG-004: Plugin Lifecycle
|
|
612
|
+
PLUG-006: Graceful Degradation
|
|
613
|
+
"""
|
|
614
|
+
with self._lock:
|
|
615
|
+
handle = self._handles.get(name)
|
|
616
|
+
if handle is None:
|
|
617
|
+
raise ValueError(f"Plugin '{name}' not found")
|
|
618
|
+
|
|
619
|
+
# Check for dependents
|
|
620
|
+
dependents = [
|
|
621
|
+
n
|
|
622
|
+
for n, h in self._handles.items()
|
|
623
|
+
if any(d.name == name for d in h.dependencies) and h.state == PluginState.ENABLED
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
if dependents and not force:
|
|
627
|
+
raise ValueError(f"Cannot disable '{name}': required by {dependents}")
|
|
628
|
+
|
|
629
|
+
# Call on_disable hook
|
|
630
|
+
if handle.instance:
|
|
631
|
+
handle.instance.on_disable()
|
|
632
|
+
|
|
633
|
+
handle.state = PluginState.DISABLED
|
|
634
|
+
self._notify_state_change(name, handle.state)
|
|
635
|
+
logger.info(f"Disabled plugin '{name}'")
|
|
636
|
+
return handle
|
|
637
|
+
|
|
638
|
+
def unload_plugin(self, name: str, force: bool = False) -> None:
|
|
639
|
+
"""Unload a plugin completely.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
name: Plugin name
|
|
643
|
+
force: Force unload even if enabled
|
|
644
|
+
|
|
645
|
+
References:
|
|
646
|
+
PLUG-004: Plugin Lifecycle
|
|
647
|
+
"""
|
|
648
|
+
with self._lock:
|
|
649
|
+
handle = self._handles.get(name)
|
|
650
|
+
if handle is None:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
if handle.state == PluginState.ENABLED and not force:
|
|
654
|
+
self.disable_plugin(name)
|
|
655
|
+
|
|
656
|
+
handle.state = PluginState.UNLOADING
|
|
657
|
+
self._notify_state_change(name, handle.state)
|
|
658
|
+
|
|
659
|
+
if handle.instance:
|
|
660
|
+
try:
|
|
661
|
+
handle.instance.on_unload()
|
|
662
|
+
except Exception as e:
|
|
663
|
+
logger.warning(f"Error during unload of '{name}': {e}")
|
|
664
|
+
|
|
665
|
+
handle.instance = None
|
|
666
|
+
handle.state = PluginState.DISCOVERED
|
|
667
|
+
self._notify_state_change(name, handle.state)
|
|
668
|
+
logger.info(f"Unloaded plugin '{name}'")
|
|
669
|
+
|
|
670
|
+
def reload_plugin(self, name: str) -> PluginHandle:
|
|
671
|
+
"""Hot reload a plugin.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
name: Plugin name
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Updated plugin handle
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
ValueError: If plugin not found
|
|
681
|
+
|
|
682
|
+
References:
|
|
683
|
+
PLUG-006: Plugin Hot Reload - state preservation, memory leak prevention
|
|
684
|
+
"""
|
|
685
|
+
with self._lock:
|
|
686
|
+
handle = self._handles.get(name)
|
|
687
|
+
if handle is None:
|
|
688
|
+
raise ValueError(f"Plugin '{name}' not found")
|
|
689
|
+
|
|
690
|
+
was_enabled = handle.state == PluginState.ENABLED
|
|
691
|
+
config = handle.instance._config if handle.instance else {}
|
|
692
|
+
|
|
693
|
+
# Preserve plugin state for restoration
|
|
694
|
+
saved_state = self._save_plugin_state(handle)
|
|
695
|
+
|
|
696
|
+
# Unload and cleanup old references
|
|
697
|
+
self.unload_plugin(name, force=True)
|
|
698
|
+
self._cleanup_plugin_references(name)
|
|
699
|
+
|
|
700
|
+
# Clear from sys.modules to force reimport
|
|
701
|
+
modules_to_clear = [mod for mod in sys.modules if mod.startswith(f"{name}.")]
|
|
702
|
+
for mod in modules_to_clear:
|
|
703
|
+
del sys.modules[mod]
|
|
704
|
+
if name in sys.modules:
|
|
705
|
+
del sys.modules[name]
|
|
706
|
+
|
|
707
|
+
# Reload
|
|
708
|
+
handle = self.load_plugin(name)
|
|
709
|
+
|
|
710
|
+
# Restore state
|
|
711
|
+
self._restore_plugin_state(handle, saved_state)
|
|
712
|
+
|
|
713
|
+
if config:
|
|
714
|
+
self.configure_plugin(name, config)
|
|
715
|
+
|
|
716
|
+
if was_enabled:
|
|
717
|
+
self.enable_plugin(name)
|
|
718
|
+
|
|
719
|
+
logger.info(f"Hot reloaded plugin '{name}'")
|
|
720
|
+
return handle
|
|
721
|
+
|
|
722
|
+
def _save_plugin_state(self, handle: PluginHandle) -> dict[str, Any]:
|
|
723
|
+
"""Save plugin state before reload.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
handle: Plugin handle
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
Saved state dictionary
|
|
730
|
+
|
|
731
|
+
References:
|
|
732
|
+
PLUG-006: Plugin Hot Reload - state preservation
|
|
733
|
+
"""
|
|
734
|
+
state: dict[str, Any] = {
|
|
735
|
+
"config": handle.instance._config if handle.instance else {},
|
|
736
|
+
"registered_protocols": (
|
|
737
|
+
handle.instance._registered_protocols.copy() if handle.instance else []
|
|
738
|
+
),
|
|
739
|
+
"registered_algorithms": (
|
|
740
|
+
handle.instance._registered_algorithms.copy() if handle.instance else []
|
|
741
|
+
),
|
|
742
|
+
}
|
|
743
|
+
return state
|
|
744
|
+
|
|
745
|
+
def _restore_plugin_state(self, handle: PluginHandle, state: dict[str, Any]) -> None:
|
|
746
|
+
"""Restore plugin state after reload.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
handle: Plugin handle
|
|
750
|
+
state: Saved state dictionary
|
|
751
|
+
|
|
752
|
+
References:
|
|
753
|
+
PLUG-006: Plugin Hot Reload - state preservation
|
|
754
|
+
"""
|
|
755
|
+
if handle.instance:
|
|
756
|
+
handle.instance._config = state.get("config", {})
|
|
757
|
+
handle.instance._registered_protocols = state.get("registered_protocols", [])
|
|
758
|
+
handle.instance._registered_algorithms = state.get("registered_algorithms", [])
|
|
759
|
+
|
|
760
|
+
def _cleanup_plugin_references(self, name: str) -> None:
|
|
761
|
+
"""Clean up plugin references to prevent memory leaks.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
name: Plugin name
|
|
765
|
+
|
|
766
|
+
References:
|
|
767
|
+
PLUG-006: Plugin Hot Reload - memory leak prevention
|
|
768
|
+
"""
|
|
769
|
+
import gc
|
|
770
|
+
|
|
771
|
+
# Remove from lazy loaders
|
|
772
|
+
if name in self._lazy_loaders:
|
|
773
|
+
del self._lazy_loaders[name]
|
|
774
|
+
|
|
775
|
+
# Force garbage collection to clean up old references
|
|
776
|
+
gc.collect()
|
|
777
|
+
|
|
778
|
+
logger.debug(f"Cleaned up references for plugin '{name}'")
|
|
779
|
+
|
|
780
|
+
def check_for_changes(self) -> list[str]:
|
|
781
|
+
"""Check for plugin file changes.
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
List of plugin names with changed files
|
|
785
|
+
|
|
786
|
+
References:
|
|
787
|
+
PLUG-008: Plugin Hot Reload
|
|
788
|
+
"""
|
|
789
|
+
changed = []
|
|
790
|
+
|
|
791
|
+
for path_str, old_mtime in self._file_watchers.items():
|
|
792
|
+
path = Path(path_str)
|
|
793
|
+
if path.exists():
|
|
794
|
+
new_mtime = path.stat().st_mtime
|
|
795
|
+
if new_mtime > old_mtime:
|
|
796
|
+
# Find plugin name
|
|
797
|
+
for name, handle in self._handles.items():
|
|
798
|
+
if handle.metadata.path and str(handle.metadata.path) in path_str:
|
|
799
|
+
changed.append(name)
|
|
800
|
+
break
|
|
801
|
+
self._file_watchers[path_str] = new_mtime
|
|
802
|
+
|
|
803
|
+
return changed
|
|
804
|
+
|
|
805
|
+
def auto_reload_changed(self) -> list[str]:
|
|
806
|
+
"""Automatically reload changed plugins.
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
List of reloaded plugin names
|
|
810
|
+
|
|
811
|
+
References:
|
|
812
|
+
PLUG-008: Plugin Hot Reload
|
|
813
|
+
"""
|
|
814
|
+
changed = self.check_for_changes()
|
|
815
|
+
reloaded = []
|
|
816
|
+
|
|
817
|
+
for name in changed:
|
|
818
|
+
try:
|
|
819
|
+
self.reload_plugin(name)
|
|
820
|
+
reloaded.append(name)
|
|
821
|
+
except Exception as e:
|
|
822
|
+
logger.error(f"Failed to auto-reload '{name}': {e}")
|
|
823
|
+
|
|
824
|
+
return reloaded
|
|
825
|
+
|
|
826
|
+
def graceful_degradation(self, name: str) -> dict[str, Any]:
|
|
827
|
+
"""Handle plugin failure gracefully.
|
|
828
|
+
|
|
829
|
+
Returns fallback options when a plugin fails.
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
name: Plugin name
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Dictionary with degradation options
|
|
836
|
+
|
|
837
|
+
References:
|
|
838
|
+
PLUG-006: Graceful Degradation
|
|
839
|
+
"""
|
|
840
|
+
handle = self._handles.get(name)
|
|
841
|
+
if handle is None:
|
|
842
|
+
return {"status": "not_found", "alternatives": []}
|
|
843
|
+
|
|
844
|
+
# Find alternatives
|
|
845
|
+
alternatives = []
|
|
846
|
+
if handle.instance:
|
|
847
|
+
# Look for plugins with same capabilities
|
|
848
|
+
for cap in handle.metadata.capabilities:
|
|
849
|
+
for other_name, other_handle in self._handles.items():
|
|
850
|
+
if other_name != name and other_handle.state == PluginState.ENABLED:
|
|
851
|
+
if cap in other_handle.metadata.capabilities:
|
|
852
|
+
alternatives.append(other_name)
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
"status": "degraded",
|
|
856
|
+
"plugin": name,
|
|
857
|
+
"error": str(handle.errors[-1].error) if handle.errors else None,
|
|
858
|
+
"alternatives": alternatives,
|
|
859
|
+
"recoverable": handle.errors[-1].recoverable if handle.errors else True,
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
def get_handle(self, name: str) -> PluginHandle | None:
|
|
863
|
+
"""Get plugin handle.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
name: Plugin name
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Plugin handle or None
|
|
870
|
+
"""
|
|
871
|
+
return self._handles.get(name)
|
|
872
|
+
|
|
873
|
+
def get_enabled_plugins(self) -> list[str]:
|
|
874
|
+
"""Get list of enabled plugins.
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
List of plugin names
|
|
878
|
+
"""
|
|
879
|
+
return [
|
|
880
|
+
name for name, handle in self._handles.items() if handle.state == PluginState.ENABLED
|
|
881
|
+
]
|
|
882
|
+
|
|
883
|
+
def on_state_change(self, callback: Callable[[str, PluginState], None]) -> None:
|
|
884
|
+
"""Register state change callback.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
callback: Function called with (plugin_name, new_state)
|
|
888
|
+
"""
|
|
889
|
+
self._lifecycle_callbacks.append(callback)
|
|
890
|
+
|
|
891
|
+
def _notify_state_change(self, name: str, state: PluginState) -> None:
|
|
892
|
+
"""Notify callbacks of state change."""
|
|
893
|
+
for callback in self._lifecycle_callbacks:
|
|
894
|
+
try:
|
|
895
|
+
callback(name, state)
|
|
896
|
+
except Exception as e:
|
|
897
|
+
logger.warning(f"State change callback failed: {e}")
|
|
898
|
+
|
|
899
|
+
def _get_plugin_path(self, name: str) -> Path:
|
|
900
|
+
"""Get path to plugin.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
name: Plugin name
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
Path to plugin
|
|
907
|
+
|
|
908
|
+
Raises:
|
|
909
|
+
ValueError: If plugin path not found
|
|
910
|
+
"""
|
|
911
|
+
for plugin_dir in self._plugin_dirs:
|
|
912
|
+
# Check for package
|
|
913
|
+
pkg_path = plugin_dir / name
|
|
914
|
+
if pkg_path.is_dir() and (pkg_path / "__init__.py").exists():
|
|
915
|
+
return pkg_path
|
|
916
|
+
# Check for single file
|
|
917
|
+
file_path = plugin_dir / f"{name}.py"
|
|
918
|
+
if file_path.exists():
|
|
919
|
+
return file_path
|
|
920
|
+
|
|
921
|
+
raise ValueError(f"Plugin path not found for '{name}'")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
# Global lifecycle manager
|
|
925
|
+
_lifecycle_manager: PluginLifecycleManager | None = None
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def get_lifecycle_manager() -> PluginLifecycleManager:
|
|
929
|
+
"""Get global lifecycle manager.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Global PluginLifecycleManager instance
|
|
933
|
+
"""
|
|
934
|
+
global _lifecycle_manager
|
|
935
|
+
if _lifecycle_manager is None:
|
|
936
|
+
_lifecycle_manager = PluginLifecycleManager()
|
|
937
|
+
return _lifecycle_manager
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def set_plugin_directories(directories: list[Path]) -> None:
|
|
941
|
+
"""Set plugin directories for global manager.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
directories: List of plugin directories
|
|
945
|
+
"""
|
|
946
|
+
global _lifecycle_manager
|
|
947
|
+
_lifecycle_manager = PluginLifecycleManager(directories)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
__all__ = [
|
|
951
|
+
"DependencyGraph",
|
|
952
|
+
"DependencyInfo",
|
|
953
|
+
"PluginHandle",
|
|
954
|
+
"PluginLifecycleManager",
|
|
955
|
+
"PluginLoadError",
|
|
956
|
+
"PluginState",
|
|
957
|
+
"get_lifecycle_manager",
|
|
958
|
+
"set_plugin_directories",
|
|
959
|
+
]
|