devscontext 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.
@@ -0,0 +1,544 @@
1
+ """Plugin registry for discovering and managing plugins.
2
+
3
+ This module provides the PluginRegistry class that handles:
4
+ - Registration of adapters and synthesis plugins
5
+ - Discovery of plugins via Python entry points
6
+ - Plugin instantiation with configuration
7
+ - Lifecycle management (initialization, cleanup)
8
+
9
+ Entry Points:
10
+ Third-party packages can register plugins via entry points in pyproject.toml:
11
+
12
+ [project.entry-points."devscontext.adapters"]
13
+ slack = "mypackage.slack:SlackAdapter"
14
+ gmail = "mypackage.gmail:GmailAdapter"
15
+
16
+ [project.entry-points."devscontext.synthesis"]
17
+ custom = "mypackage.synthesis:CustomSynthesis"
18
+
19
+ Example Usage:
20
+ # Create registry and discover plugins
21
+ registry = PluginRegistry()
22
+ registry.discover_plugins()
23
+
24
+ # Register built-in adapters
25
+ registry.register_adapter(JiraAdapter)
26
+ registry.register_synthesis(LLMSynthesisPlugin)
27
+
28
+ # Instantiate plugins with config
29
+ jira = registry.create_adapter("jira", jira_config)
30
+ synthesis = registry.create_synthesis("llm", synthesis_config)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ from devscontext.logging import get_logger
38
+ from devscontext.plugins.base import Adapter, SynthesisPlugin # noqa: TC001 - used at runtime
39
+
40
+ if TYPE_CHECKING:
41
+ from pydantic import BaseModel
42
+
43
+ from devscontext.models import DevsContextConfig
44
+
45
+ logger = get_logger(__name__)
46
+
47
+ # Entry point group names
48
+ ADAPTER_ENTRY_POINT = "devscontext.adapters"
49
+ SYNTHESIS_ENTRY_POINT = "devscontext.synthesis"
50
+
51
+
52
+ class PluginRegistry:
53
+ """Central registry for discovering and managing plugins.
54
+
55
+ The registry maintains mappings of plugin names to their classes,
56
+ handles discovery via entry points, and manages plugin instantiation.
57
+
58
+ Attributes:
59
+ adapter_classes: Mapping of adapter names to classes.
60
+ synthesis_classes: Mapping of synthesis plugin names to classes.
61
+ _adapter_instances: Active adapter instances.
62
+ _synthesis_instance: Active synthesis plugin instance.
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ """Initialize an empty plugin registry."""
67
+ # Plugin class registrations
68
+ self._adapter_classes: dict[str, type[Adapter]] = {}
69
+ self._synthesis_classes: dict[str, type[SynthesisPlugin]] = {}
70
+
71
+ # Active plugin instances
72
+ self._adapter_instances: dict[str, Adapter] = {}
73
+ self._synthesis_instance: SynthesisPlugin | None = None
74
+
75
+ # =========================================================================
76
+ # BUILT-IN REGISTRATION
77
+ # =========================================================================
78
+
79
+ def register_builtin_plugins(self) -> None:
80
+ """Register all built-in adapters and synthesis plugins.
81
+
82
+ This registers:
83
+ - JiraAdapter
84
+ - FirefliesAdapter
85
+ - LocalDocsAdapter
86
+ - SlackAdapter
87
+ - GmailAdapter
88
+ - LLMSynthesisPlugin
89
+
90
+ Call this before load_from_config() to ensure built-in plugins
91
+ are available for instantiation.
92
+ """
93
+ # Import here to avoid circular imports
94
+ from devscontext.adapters.fireflies import FirefliesAdapter
95
+ from devscontext.adapters.gmail import GmailAdapter
96
+ from devscontext.adapters.jira import JiraAdapter
97
+ from devscontext.adapters.local_docs import LocalDocsAdapter
98
+ from devscontext.adapters.slack import SlackAdapter
99
+ from devscontext.synthesis import (
100
+ LLMSynthesisPlugin,
101
+ PassthroughSynthesisPlugin,
102
+ TemplateSynthesisPlugin,
103
+ )
104
+
105
+ # Register adapters
106
+ self.register_adapter(JiraAdapter)
107
+ self.register_adapter(FirefliesAdapter)
108
+ self.register_adapter(LocalDocsAdapter)
109
+ self.register_adapter(SlackAdapter)
110
+ self.register_adapter(GmailAdapter)
111
+
112
+ # Register synthesis plugins
113
+ self.register_synthesis(LLMSynthesisPlugin)
114
+ self.register_synthesis(TemplateSynthesisPlugin)
115
+ self.register_synthesis(PassthroughSynthesisPlugin)
116
+
117
+ logger.debug("Registered all built-in plugins")
118
+
119
+ def load_from_config(self, config: DevsContextConfig) -> None:
120
+ """Initialize adapters and synthesis from configuration.
121
+
122
+ Reads the config and creates instances for each enabled source.
123
+ Sources with missing required config or disabled are skipped.
124
+
125
+ Args:
126
+ config: The full DevsContext configuration.
127
+
128
+ Plugin loading flow:
129
+ 1. Read sources section from config
130
+ 2. For each source, check if enabled
131
+ 3. Look up in built-in plugins first, then entry points
132
+ 4. Initialize with source-specific config
133
+ 5. Skip sources with missing or invalid config
134
+ """
135
+ sources = config.sources
136
+
137
+ # Load Jira adapter if enabled
138
+ if sources.jira.enabled:
139
+ if sources.jira.base_url and sources.jira.email:
140
+ try:
141
+ self.create_adapter("jira", sources.jira)
142
+ logger.info("Loaded Jira adapter")
143
+ except Exception as e:
144
+ logger.warning(f"Failed to load Jira adapter: {e}")
145
+ else:
146
+ logger.debug("Jira adapter skipped: missing base_url or email")
147
+
148
+ # Load Fireflies adapter if enabled
149
+ if sources.fireflies.enabled:
150
+ if sources.fireflies.api_key:
151
+ try:
152
+ self.create_adapter("fireflies", sources.fireflies)
153
+ logger.info("Loaded Fireflies adapter")
154
+ except Exception as e:
155
+ logger.warning(f"Failed to load Fireflies adapter: {e}")
156
+ else:
157
+ logger.debug("Fireflies adapter skipped: missing api_key")
158
+
159
+ # Load local docs adapter if enabled
160
+ if sources.docs.enabled:
161
+ try:
162
+ self.create_adapter("local_docs", sources.docs)
163
+ logger.info("Loaded LocalDocs adapter")
164
+ except Exception as e:
165
+ logger.warning(f"Failed to load LocalDocs adapter: {e}")
166
+
167
+ # Load Slack adapter if enabled
168
+ if sources.slack.enabled:
169
+ if sources.slack.bot_token:
170
+ try:
171
+ self.create_adapter("slack", sources.slack)
172
+ logger.info("Loaded Slack adapter")
173
+ except Exception as e:
174
+ logger.warning(f"Failed to load Slack adapter: {e}")
175
+ else:
176
+ logger.debug("Slack adapter skipped: missing bot_token")
177
+
178
+ # Load Gmail adapter if enabled
179
+ if sources.gmail.enabled:
180
+ if sources.gmail.credentials_path:
181
+ try:
182
+ self.create_adapter("gmail", sources.gmail)
183
+ logger.info("Loaded Gmail adapter")
184
+ except Exception as e:
185
+ logger.warning(f"Failed to load Gmail adapter: {e}")
186
+ else:
187
+ logger.debug("Gmail adapter skipped: missing credentials_path")
188
+
189
+ # Load synthesis plugin
190
+ synthesis_config = config.synthesis
191
+ plugin_name = synthesis_config.plugin
192
+
193
+ if plugin_name in self._synthesis_classes:
194
+ try:
195
+ self.create_synthesis(plugin_name, synthesis_config)
196
+ logger.info(f"Loaded synthesis plugin: {plugin_name}")
197
+ except Exception as e:
198
+ logger.warning(f"Failed to load synthesis plugin '{plugin_name}': {e}")
199
+ else:
200
+ logger.warning(
201
+ f"Unknown synthesis plugin '{plugin_name}', "
202
+ f"available: {list(self._synthesis_classes.keys())}"
203
+ )
204
+
205
+ def get_primary_adapters(self) -> dict[str, Adapter]:
206
+ """Get all active primary adapters.
207
+
208
+ Primary adapters are fetched first and their context is shared
209
+ with secondary adapters.
210
+
211
+ Returns:
212
+ Dict mapping adapter names to primary adapter instances.
213
+ """
214
+ primary: dict[str, Adapter] = {}
215
+ for name, adapter in self._adapter_instances.items():
216
+ # Check if adapter config has primary=True
217
+ config = getattr(adapter, "_config", None)
218
+ if config is not None and getattr(config, "primary", False):
219
+ primary[name] = adapter
220
+ return primary
221
+
222
+ def get_secondary_adapters(self) -> dict[str, Adapter]:
223
+ """Get all active secondary adapters.
224
+
225
+ Secondary adapters are fetched after primary adapters and
226
+ can use primary context for better matching.
227
+
228
+ Returns:
229
+ Dict mapping adapter names to secondary adapter instances.
230
+ """
231
+ secondary: dict[str, Adapter] = {}
232
+ for name, adapter in self._adapter_instances.items():
233
+ # Check if adapter config has primary=False or not set
234
+ config = getattr(adapter, "_config", None)
235
+ if config is None or not getattr(config, "primary", False):
236
+ secondary[name] = adapter
237
+ return secondary
238
+
239
+ # =========================================================================
240
+ # REGISTRATION
241
+ # =========================================================================
242
+
243
+ def register_adapter(self, adapter_class: type[Adapter]) -> None:
244
+ """Register an adapter class.
245
+
246
+ Args:
247
+ adapter_class: The Adapter subclass to register.
248
+
249
+ Raises:
250
+ ValueError: If adapter name conflicts with existing registration.
251
+ """
252
+ name = adapter_class.name
253
+ if name in self._adapter_classes:
254
+ existing = self._adapter_classes[name]
255
+ if existing is not adapter_class:
256
+ raise ValueError(f"Adapter '{name}' already registered by {existing.__module__}")
257
+ return # Already registered same class
258
+
259
+ self._adapter_classes[name] = adapter_class
260
+ logger.debug(f"Registered adapter: {name} ({adapter_class.__module__})")
261
+
262
+ def register_synthesis(self, plugin_class: type[SynthesisPlugin]) -> None:
263
+ """Register a synthesis plugin class.
264
+
265
+ Args:
266
+ plugin_class: The SynthesisPlugin subclass to register.
267
+
268
+ Raises:
269
+ ValueError: If plugin name conflicts with existing registration.
270
+ """
271
+ name = plugin_class.name
272
+ if name in self._synthesis_classes:
273
+ existing = self._synthesis_classes[name]
274
+ if existing is not plugin_class:
275
+ raise ValueError(
276
+ f"Synthesis plugin '{name}' already registered by {existing.__module__}"
277
+ )
278
+ return # Already registered same class
279
+
280
+ self._synthesis_classes[name] = plugin_class
281
+ logger.debug(f"Registered synthesis plugin: {name} ({plugin_class.__module__})")
282
+
283
+ # =========================================================================
284
+ # DISCOVERY
285
+ # =========================================================================
286
+
287
+ def discover_plugins(self) -> None:
288
+ """Discover and register plugins from entry points.
289
+
290
+ Scans for plugins registered via:
291
+ - devscontext.adapters - adapter plugins
292
+ - devscontext.synthesis - synthesis plugins
293
+
294
+ Errors during discovery are logged but don't stop the process.
295
+ """
296
+ self._discover_entry_points(ADAPTER_ENTRY_POINT, self.register_adapter)
297
+ self._discover_entry_points(SYNTHESIS_ENTRY_POINT, self.register_synthesis)
298
+
299
+ def _discover_entry_points(
300
+ self,
301
+ group: str,
302
+ register_fn: Any,
303
+ ) -> None:
304
+ """Discover plugins from a specific entry point group.
305
+
306
+ Args:
307
+ group: Entry point group name.
308
+ register_fn: Function to call for each discovered plugin.
309
+ """
310
+ from importlib.metadata import entry_points
311
+
312
+ eps = entry_points(group=group)
313
+
314
+ for ep in eps:
315
+ try:
316
+ plugin_class = ep.load()
317
+ register_fn(plugin_class)
318
+ logger.info(f"Discovered plugin via entry point: {ep.name}")
319
+ except Exception as e:
320
+ logger.warning(
321
+ f"Failed to load plugin from entry point {ep.name}: {e}",
322
+ extra={"entry_point": ep.name, "group": group},
323
+ )
324
+
325
+ # =========================================================================
326
+ # INSTANTIATION
327
+ # =========================================================================
328
+
329
+ def create_adapter(
330
+ self,
331
+ name: str,
332
+ config: BaseModel,
333
+ ) -> Adapter:
334
+ """Create and return an adapter instance.
335
+
336
+ If an instance already exists for this name, returns the existing one.
337
+
338
+ Args:
339
+ name: The adapter name (e.g., "jira").
340
+ config: Configuration for the adapter (must match adapter's config_schema).
341
+
342
+ Returns:
343
+ The adapter instance.
344
+
345
+ Raises:
346
+ KeyError: If no adapter is registered with this name.
347
+ TypeError: If config doesn't match the adapter's config_schema.
348
+ """
349
+ if name in self._adapter_instances:
350
+ return self._adapter_instances[name]
351
+
352
+ if name not in self._adapter_classes:
353
+ raise KeyError(f"No adapter registered with name '{name}'")
354
+
355
+ adapter_class = self._adapter_classes[name]
356
+
357
+ # Validate config type
358
+ expected_schema = adapter_class.config_schema
359
+ if not isinstance(config, expected_schema):
360
+ raise TypeError(
361
+ f"Adapter '{name}' expects config of type {expected_schema.__name__}, "
362
+ f"got {type(config).__name__}"
363
+ )
364
+
365
+ instance = adapter_class(config) # type: ignore[call-arg]
366
+ self._adapter_instances[name] = instance
367
+ logger.debug(f"Created adapter instance: {name}")
368
+
369
+ return instance
370
+
371
+ def create_synthesis(
372
+ self,
373
+ name: str,
374
+ config: BaseModel,
375
+ ) -> SynthesisPlugin:
376
+ """Create and return a synthesis plugin instance.
377
+
378
+ Only one synthesis plugin can be active at a time.
379
+
380
+ Args:
381
+ name: The plugin name (e.g., "llm").
382
+ config: Configuration for the plugin.
383
+
384
+ Returns:
385
+ The plugin instance.
386
+
387
+ Raises:
388
+ KeyError: If no plugin is registered with this name.
389
+ TypeError: If config doesn't match the plugin's config_schema.
390
+ """
391
+ if self._synthesis_instance is not None:
392
+ # Check if it's the same type
393
+ if self._synthesis_instance.name == name:
394
+ return self._synthesis_instance
395
+ # Close existing before creating new
396
+ logger.debug(f"Replacing synthesis plugin: {self._synthesis_instance.name} -> {name}")
397
+
398
+ if name not in self._synthesis_classes:
399
+ raise KeyError(f"No synthesis plugin registered with name '{name}'")
400
+
401
+ plugin_class = self._synthesis_classes[name]
402
+
403
+ # Validate config type
404
+ expected_schema = plugin_class.config_schema
405
+ if not isinstance(config, expected_schema):
406
+ raise TypeError(
407
+ f"Plugin '{name}' expects config of type {expected_schema.__name__}, "
408
+ f"got {type(config).__name__}"
409
+ )
410
+
411
+ instance = plugin_class(config) # type: ignore[call-arg]
412
+ self._synthesis_instance = instance
413
+ logger.debug(f"Created synthesis plugin instance: {name}")
414
+
415
+ return instance
416
+
417
+ # =========================================================================
418
+ # INSTANCE ACCESS
419
+ # =========================================================================
420
+
421
+ def get_adapter(self, name: str) -> Adapter | None:
422
+ """Get an existing adapter instance.
423
+
424
+ Args:
425
+ name: The adapter name.
426
+
427
+ Returns:
428
+ The adapter instance, or None if not instantiated.
429
+ """
430
+ return self._adapter_instances.get(name)
431
+
432
+ def get_synthesis(self) -> SynthesisPlugin | None:
433
+ """Get the active synthesis plugin instance.
434
+
435
+ Returns:
436
+ The synthesis plugin, or None if not instantiated.
437
+ """
438
+ return self._synthesis_instance
439
+
440
+ def get_active_adapters(self) -> dict[str, Adapter]:
441
+ """Get all active adapter instances.
442
+
443
+ Returns:
444
+ Dict mapping adapter names to instances.
445
+ """
446
+ return dict(self._adapter_instances)
447
+
448
+ # =========================================================================
449
+ # INTROSPECTION
450
+ # =========================================================================
451
+
452
+ def list_adapters(self) -> list[str]:
453
+ """List all registered adapter names.
454
+
455
+ Returns:
456
+ List of adapter names.
457
+ """
458
+ return list(self._adapter_classes.keys())
459
+
460
+ def list_synthesis_plugins(self) -> list[str]:
461
+ """List all registered synthesis plugin names.
462
+
463
+ Returns:
464
+ List of plugin names.
465
+ """
466
+ return list(self._synthesis_classes.keys())
467
+
468
+ def get_adapter_config_schema(self, name: str) -> type[BaseModel]:
469
+ """Get the configuration schema for an adapter.
470
+
471
+ Args:
472
+ name: The adapter name.
473
+
474
+ Returns:
475
+ The Pydantic model class for the adapter's config.
476
+
477
+ Raises:
478
+ KeyError: If no adapter is registered with this name.
479
+ """
480
+ if name not in self._adapter_classes:
481
+ raise KeyError(f"No adapter registered with name '{name}'")
482
+ return self._adapter_classes[name].config_schema
483
+
484
+ def get_synthesis_config_schema(self, name: str) -> type[BaseModel]:
485
+ """Get the configuration schema for a synthesis plugin.
486
+
487
+ Args:
488
+ name: The plugin name.
489
+
490
+ Returns:
491
+ The Pydantic model class for the plugin's config.
492
+
493
+ Raises:
494
+ KeyError: If no plugin is registered with this name.
495
+ """
496
+ if name not in self._synthesis_classes:
497
+ raise KeyError(f"No synthesis plugin registered with name '{name}'")
498
+ return self._synthesis_classes[name].config_schema
499
+
500
+ # =========================================================================
501
+ # LIFECYCLE
502
+ # =========================================================================
503
+
504
+ async def close_all(self) -> None:
505
+ """Close all plugin instances and clean up resources.
506
+
507
+ Should be called during shutdown to properly release resources.
508
+ """
509
+ # Close adapters
510
+ for name, adapter in list(self._adapter_instances.items()):
511
+ try:
512
+ await adapter.close()
513
+ logger.debug(f"Closed adapter: {name}")
514
+ except Exception as e:
515
+ logger.warning(f"Error closing adapter {name}: {e}")
516
+
517
+ self._adapter_instances.clear()
518
+
519
+ # Close synthesis plugin
520
+ if self._synthesis_instance is not None:
521
+ try:
522
+ await self._synthesis_instance.close()
523
+ logger.debug(f"Closed synthesis plugin: {self._synthesis_instance.name}")
524
+ except Exception as e:
525
+ logger.warning(f"Error closing synthesis plugin: {e}")
526
+
527
+ self._synthesis_instance = None
528
+
529
+ async def health_check_all(self) -> dict[str, bool]:
530
+ """Run health checks on all active adapters.
531
+
532
+ Returns:
533
+ Dict mapping adapter names to health status.
534
+ """
535
+ results: dict[str, bool] = {}
536
+
537
+ for name, adapter in self._adapter_instances.items():
538
+ try:
539
+ results[name] = await adapter.health_check()
540
+ except Exception as e:
541
+ logger.warning(f"Health check failed for {name}: {e}")
542
+ results[name] = False
543
+
544
+ return results
devscontext/py.typed ADDED
File without changes
@@ -0,0 +1,113 @@
1
+ """RAG (Retrieval-Augmented Generation) support for local documentation.
2
+
3
+ This module provides optional embedding-based search for the LocalDocsAdapter.
4
+ When enabled, it uses semantic similarity instead of keyword matching for
5
+ finding relevant documentation sections.
6
+
7
+ The RAG feature is optional and requires additional dependencies:
8
+ pip install devscontext[rag]
9
+
10
+ Example usage:
11
+ from devscontext.rag import DocumentIndex, get_embedding_provider
12
+
13
+ # Create embedding provider
14
+ provider = get_embedding_provider(rag_config)
15
+
16
+ # Create and load index
17
+ index = DocumentIndex(rag_config.index_path)
18
+ index.load()
19
+
20
+ # Search for similar documents
21
+ query_embedding = await provider.embed_query("payment webhook retry logic")
22
+ results = index.search(query_embedding, top_k=10, threshold=0.3)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ if TYPE_CHECKING:
30
+ from devscontext.models import RagConfig
31
+ from devscontext.rag.embeddings import EmbeddingProvider
32
+
33
+ # Lazy imports to avoid loading heavy dependencies unless RAG is used
34
+ _RAG_AVAILABLE: bool | None = None
35
+
36
+
37
+ def is_rag_available() -> bool:
38
+ """Check if RAG dependencies are installed.
39
+
40
+ Returns:
41
+ True if sentence-transformers and numpy are available.
42
+ """
43
+ global _RAG_AVAILABLE
44
+ if _RAG_AVAILABLE is None:
45
+ try:
46
+ import numpy # noqa: F401
47
+ import sentence_transformers # noqa: F401
48
+
49
+ _RAG_AVAILABLE = True
50
+ except ImportError:
51
+ _RAG_AVAILABLE = False
52
+ return _RAG_AVAILABLE
53
+
54
+
55
+ def get_embedding_provider(config: RagConfig) -> EmbeddingProvider:
56
+ """Factory function to create an embedding provider based on config.
57
+
58
+ Args:
59
+ config: RAG configuration specifying provider and model.
60
+
61
+ Returns:
62
+ An EmbeddingProvider instance.
63
+
64
+ Raises:
65
+ ImportError: If RAG dependencies are not installed.
66
+ ValueError: If the embedding provider is not supported.
67
+ """
68
+ if not is_rag_available():
69
+ raise ImportError(
70
+ "RAG dependencies not installed. Install with: pip install devscontext[rag]"
71
+ )
72
+
73
+ from devscontext.rag.embeddings import (
74
+ LocalEmbeddingProvider,
75
+ OllamaEmbeddingProvider,
76
+ OpenAIEmbeddingProvider,
77
+ )
78
+
79
+ providers: dict[str, type[EmbeddingProvider]] = {
80
+ "local": LocalEmbeddingProvider,
81
+ "openai": OpenAIEmbeddingProvider,
82
+ "ollama": OllamaEmbeddingProvider,
83
+ }
84
+
85
+ provider_cls = providers.get(config.embedding_provider)
86
+ if provider_cls is None:
87
+ raise ValueError(
88
+ f"Unknown embedding provider: {config.embedding_provider}. "
89
+ f"Supported: {', '.join(providers.keys())}"
90
+ )
91
+
92
+ return provider_cls(config.embedding_model)
93
+
94
+
95
+ __all__ = [
96
+ "DocumentIndex",
97
+ "EmbeddingProvider",
98
+ "get_embedding_provider",
99
+ "is_rag_available",
100
+ ]
101
+
102
+
103
+ def __getattr__(name: str) -> type:
104
+ """Lazy import of RAG classes to avoid loading dependencies at import time."""
105
+ if name == "DocumentIndex":
106
+ from devscontext.rag.index import DocumentIndex
107
+
108
+ return DocumentIndex
109
+ if name == "EmbeddingProvider":
110
+ from devscontext.rag.embeddings import EmbeddingProvider
111
+
112
+ return EmbeddingProvider
113
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")