hexproxy 0.2.2__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.
hexproxy/extensions.py ADDED
@@ -0,0 +1,739 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import importlib.util
5
+ import inspect
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+ from typing import Any, Callable, Protocol, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .preferences import ApplicationPreferences
12
+ from .proxy import ParsedRequest, ParsedResponse
13
+ from .store import PendingInterceptionView, TrafficStore
14
+ from .models import TrafficEntry
15
+ from .themes import ThemeManager
16
+
17
+
18
+ BUILTIN_WORKSPACE_IDS = (
19
+ "overview",
20
+ "intercept",
21
+ "repeater",
22
+ "sitemap",
23
+ "match_replace",
24
+ "http",
25
+ "export",
26
+ "settings",
27
+ "scope",
28
+ "filters",
29
+ "keybindings",
30
+ "rule_builder",
31
+ "theme_builder",
32
+ )
33
+ SETTING_FIELD_KINDS = ("toggle", "choice", "text", "action")
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class HookContext:
38
+ entry_id: int
39
+ client_addr: str
40
+ store: "TrafficStore"
41
+ plugin_manager: "PluginManager | None" = None
42
+ tags: dict[str, str] = field(default_factory=dict)
43
+ metadata: dict[str, dict[str, str]] = field(default_factory=dict)
44
+ findings: dict[str, list[str]] = field(default_factory=dict)
45
+
46
+ def set_metadata(self, plugin_id: str, key: str, value: object) -> None:
47
+ plugin_name = str(plugin_id).strip()
48
+ field_name = str(key).strip()
49
+ if not plugin_name or not field_name:
50
+ return
51
+ bucket = dict(self.metadata.get(plugin_name, {}))
52
+ bucket[field_name] = str(value)
53
+ self.metadata[plugin_name] = bucket
54
+
55
+ def add_finding(self, plugin_id: str, text: object) -> None:
56
+ plugin_name = str(plugin_id).strip()
57
+ message = str(text).strip()
58
+ if not plugin_name or not message:
59
+ return
60
+ notes = list(self.findings.get(plugin_name, []))
61
+ notes.append(message)
62
+ self.findings[plugin_name] = notes
63
+
64
+ def global_state(self, plugin_id: str) -> dict[str, object]:
65
+ if self.plugin_manager is None:
66
+ return {}
67
+ return self.plugin_manager.global_state(plugin_id)
68
+
69
+ def set_global_value(self, plugin_id: str, key: str, value: object) -> None:
70
+ if self.plugin_manager is None:
71
+ return
72
+ self.plugin_manager.set_global_value(plugin_id, key, value)
73
+
74
+ def project_state(self, plugin_id: str) -> dict[str, object]:
75
+ if self.plugin_manager is None:
76
+ return {}
77
+ return self.plugin_manager.project_state(plugin_id)
78
+
79
+ def set_project_value(self, plugin_id: str, key: str, value: object) -> None:
80
+ if self.plugin_manager is None:
81
+ return
82
+ self.plugin_manager.set_project_value(plugin_id, key, value)
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class PluginRenderContext:
87
+ plugin_id: str
88
+ plugin_manager: "PluginManager"
89
+ store: "TrafficStore"
90
+ entry: "TrafficEntry | None" = None
91
+ request: "ParsedRequest | None" = None
92
+ response: "ParsedResponse | None" = None
93
+ intercept: "PendingInterceptionView | None" = None
94
+ export_source: object | None = None
95
+ tui: object | None = None
96
+ workspace_id: str = ""
97
+ panel_id: str = ""
98
+
99
+ def set_status(self, message: str) -> None:
100
+ if self.tui is None:
101
+ return
102
+ setter = getattr(self.tui, "_set_status", None)
103
+ if callable(setter):
104
+ setter(message)
105
+
106
+ def open_workspace(self, workspace_id: str) -> None:
107
+ if self.tui is None:
108
+ return
109
+ opener = getattr(self.tui, "open_workspace_by_id", None)
110
+ if callable(opener):
111
+ opener(workspace_id)
112
+
113
+ def global_state(self, plugin_id: str | None = None) -> dict[str, object]:
114
+ return self.plugin_manager.global_state(plugin_id or self.plugin_id)
115
+
116
+ def set_global_value(self, key: str, value: object, plugin_id: str | None = None) -> None:
117
+ self.plugin_manager.set_global_value(plugin_id or self.plugin_id, key, value)
118
+
119
+ def project_state(self, plugin_id: str | None = None) -> dict[str, object]:
120
+ return self.plugin_manager.project_state(plugin_id or self.plugin_id)
121
+
122
+ def set_project_value(self, key: str, value: object, plugin_id: str | None = None) -> None:
123
+ self.plugin_manager.set_project_value(plugin_id or self.plugin_id, key, value)
124
+
125
+ def theme_manager(self) -> "ThemeManager | None":
126
+ return self.plugin_manager.theme_manager()
127
+
128
+
129
+ class HexProxyPlugin(Protocol):
130
+ name: str
131
+
132
+ def on_loaded(self) -> None: ...
133
+
134
+ def before_request_forward(self, context: HookContext, request: "ParsedRequest") -> "ParsedRequest": ...
135
+
136
+ def on_response_received(
137
+ self,
138
+ context: HookContext,
139
+ request: "ParsedRequest",
140
+ response: "ParsedResponse",
141
+ ) -> None: ...
142
+
143
+ def on_error(self, context: HookContext, error: Exception) -> None: ...
144
+
145
+
146
+ @dataclass(slots=True)
147
+ class LoadedPlugin:
148
+ plugin_id: str
149
+ name: str
150
+ path: Path
151
+ instance: object
152
+
153
+
154
+ @dataclass(slots=True)
155
+ class PluginWorkspaceContribution:
156
+ plugin_id: str
157
+ workspace_id: str
158
+ label: str
159
+ description: str = ""
160
+ order: int = 100
161
+ shortcut: str = ""
162
+
163
+
164
+ @dataclass(slots=True)
165
+ class PluginPanelContribution:
166
+ plugin_id: str
167
+ workspace_id: str
168
+ panel_id: str
169
+ title: str
170
+ description: str = ""
171
+ order: int = 100
172
+ render_lines: Callable[[PluginRenderContext], str | list[str] | None] | None = None
173
+
174
+
175
+ @dataclass(slots=True)
176
+ class PluginExporterContribution:
177
+ plugin_id: str
178
+ exporter_id: str
179
+ label: str
180
+ description: str
181
+ render: Callable[[PluginRenderContext], str]
182
+ order: int = 100
183
+ style_kind: str | None = None
184
+
185
+
186
+ @dataclass(slots=True)
187
+ class PluginKeybindingContribution:
188
+ plugin_id: str
189
+ action: str
190
+ key: str
191
+ description: str
192
+ handler: Callable[[PluginRenderContext], bool | None]
193
+ section: str = "Plugin Actions"
194
+
195
+
196
+ @dataclass(slots=True)
197
+ class PluginAnalyzerContribution:
198
+ plugin_id: str
199
+ analyzer_id: str
200
+ label: str
201
+ description: str = ""
202
+ order: int = 100
203
+ analyze: Callable[[PluginRenderContext], str | list[str] | None] | None = None
204
+
205
+
206
+ @dataclass(slots=True)
207
+ class PluginMetadataContribution:
208
+ plugin_id: str
209
+ metadata_id: str
210
+ label: str
211
+ description: str = ""
212
+ order: int = 100
213
+ collect: Callable[[PluginRenderContext], dict[str, object] | list[tuple[str, object]] | None] | None = None
214
+
215
+
216
+ @dataclass(slots=True)
217
+ class PluginSettingFieldContribution:
218
+ plugin_id: str
219
+ field_id: str
220
+ section: str
221
+ label: str
222
+ description: str
223
+ kind: str
224
+ scope: str = "global"
225
+ default: object = None
226
+ options: list[str] = field(default_factory=list)
227
+ placeholder: str = ""
228
+ action_label: str = "Run"
229
+ on_change: Callable[[PluginRenderContext, object], object | None] | None = None
230
+
231
+
232
+ class PluginAPI:
233
+ def __init__(self, manager: "PluginManager", plugin_id: str) -> None:
234
+ self._manager = manager
235
+ self._plugin_id = plugin_id
236
+
237
+ @property
238
+ def plugin_id(self) -> str:
239
+ return self._plugin_id
240
+
241
+ def set_plugin_id(self, plugin_id: str) -> None:
242
+ normalized = str(plugin_id).strip()
243
+ if not normalized:
244
+ raise ValueError("plugin id must not be empty")
245
+ if normalized == self._plugin_id:
246
+ return
247
+ self._manager._reassign_plugin_id(self._plugin_id, normalized)
248
+ self._plugin_id = normalized
249
+
250
+ def add_workspace(
251
+ self,
252
+ workspace_id: str,
253
+ label: str,
254
+ description: str = "",
255
+ *,
256
+ order: int = 100,
257
+ shortcut: str = "",
258
+ ) -> None:
259
+ self._manager.register_workspace(
260
+ PluginWorkspaceContribution(
261
+ plugin_id=self._plugin_id,
262
+ workspace_id=str(workspace_id).strip(),
263
+ label=str(label).strip(),
264
+ description=str(description),
265
+ order=int(order),
266
+ shortcut=str(shortcut),
267
+ )
268
+ )
269
+
270
+ def add_panel(
271
+ self,
272
+ workspace_id: str,
273
+ panel_id: str,
274
+ title: str,
275
+ *,
276
+ description: str = "",
277
+ order: int = 100,
278
+ render_lines: Callable[[PluginRenderContext], str | list[str] | None] | None = None,
279
+ ) -> None:
280
+ self._manager.register_panel(
281
+ PluginPanelContribution(
282
+ plugin_id=self._plugin_id,
283
+ workspace_id=str(workspace_id).strip(),
284
+ panel_id=str(panel_id).strip(),
285
+ title=str(title).strip(),
286
+ description=str(description),
287
+ order=int(order),
288
+ render_lines=render_lines,
289
+ )
290
+ )
291
+
292
+ def add_exporter(
293
+ self,
294
+ exporter_id: str,
295
+ label: str,
296
+ description: str,
297
+ *,
298
+ render: Callable[[PluginRenderContext], str],
299
+ order: int = 100,
300
+ style_kind: str | None = None,
301
+ ) -> None:
302
+ self._manager.register_exporter(
303
+ PluginExporterContribution(
304
+ plugin_id=self._plugin_id,
305
+ exporter_id=str(exporter_id).strip(),
306
+ label=str(label).strip(),
307
+ description=str(description),
308
+ render=render,
309
+ order=int(order),
310
+ style_kind=style_kind,
311
+ )
312
+ )
313
+
314
+ def add_keybinding(
315
+ self,
316
+ action: str,
317
+ key: str,
318
+ description: str,
319
+ *,
320
+ handler: Callable[[PluginRenderContext], bool | None],
321
+ section: str = "Plugin Actions",
322
+ ) -> None:
323
+ self._manager.register_keybinding(
324
+ PluginKeybindingContribution(
325
+ plugin_id=self._plugin_id,
326
+ action=str(action).strip(),
327
+ key=str(key),
328
+ description=str(description),
329
+ handler=handler,
330
+ section=str(section).strip() or "Plugin Actions",
331
+ )
332
+ )
333
+
334
+ def add_analyzer(
335
+ self,
336
+ analyzer_id: str,
337
+ label: str,
338
+ *,
339
+ description: str = "",
340
+ order: int = 100,
341
+ analyze: Callable[[PluginRenderContext], str | list[str] | None] | None = None,
342
+ ) -> None:
343
+ self._manager.register_analyzer(
344
+ PluginAnalyzerContribution(
345
+ plugin_id=self._plugin_id,
346
+ analyzer_id=str(analyzer_id).strip(),
347
+ label=str(label).strip(),
348
+ description=str(description),
349
+ order=int(order),
350
+ analyze=analyze,
351
+ )
352
+ )
353
+
354
+ def add_metadata(
355
+ self,
356
+ metadata_id: str,
357
+ label: str,
358
+ *,
359
+ description: str = "",
360
+ order: int = 100,
361
+ collect: Callable[[PluginRenderContext], dict[str, object] | list[tuple[str, object]] | None] | None = None,
362
+ ) -> None:
363
+ self._manager.register_metadata(
364
+ PluginMetadataContribution(
365
+ plugin_id=self._plugin_id,
366
+ metadata_id=str(metadata_id).strip(),
367
+ label=str(label).strip(),
368
+ description=str(description),
369
+ order=int(order),
370
+ collect=collect,
371
+ )
372
+ )
373
+
374
+ def add_setting_field(
375
+ self,
376
+ field_id: str,
377
+ section: str,
378
+ label: str,
379
+ description: str,
380
+ *,
381
+ kind: str,
382
+ scope: str = "global",
383
+ default: object = None,
384
+ options: list[str] | None = None,
385
+ placeholder: str = "",
386
+ action_label: str = "Run",
387
+ on_change: Callable[[PluginRenderContext, object], object | None] | None = None,
388
+ ) -> None:
389
+ kind_name = str(kind).strip()
390
+ if kind_name not in SETTING_FIELD_KINDS:
391
+ raise ValueError(f"unsupported setting field kind {kind_name!r}")
392
+ scope_name = str(scope).strip().lower() or "global"
393
+ if scope_name not in {"global", "project"}:
394
+ raise ValueError("setting field scope must be 'global' or 'project'")
395
+ self._manager.register_setting_field(
396
+ PluginSettingFieldContribution(
397
+ plugin_id=self._plugin_id,
398
+ field_id=str(field_id).strip(),
399
+ section=str(section).strip() or "Plugin Settings",
400
+ label=str(label).strip(),
401
+ description=str(description),
402
+ kind=kind_name,
403
+ scope=scope_name,
404
+ default=default,
405
+ options=list(options or []),
406
+ placeholder=str(placeholder),
407
+ action_label=str(action_label).strip() or "Run",
408
+ on_change=on_change,
409
+ )
410
+ )
411
+
412
+
413
+ class PluginManager:
414
+ def __init__(self) -> None:
415
+ self._plugins: list[LoadedPlugin] = []
416
+ self._load_errors: list[str] = []
417
+ self._plugin_dirs: list[Path] = []
418
+ self._workspaces: list[PluginWorkspaceContribution] = []
419
+ self._panels: list[PluginPanelContribution] = []
420
+ self._exporters: list[PluginExporterContribution] = []
421
+ self._keybindings: list[PluginKeybindingContribution] = []
422
+ self._analyzers: list[PluginAnalyzerContribution] = []
423
+ self._metadata: list[PluginMetadataContribution] = []
424
+ self._setting_fields: list[PluginSettingFieldContribution] = []
425
+ self._store: TrafficStore | None = None
426
+ self._preferences: ApplicationPreferences | None = None
427
+ self._theme_manager: ThemeManager | None = None
428
+
429
+ def bind_runtime(
430
+ self,
431
+ *,
432
+ store: "TrafficStore | None" = None,
433
+ preferences: "ApplicationPreferences | None" = None,
434
+ theme_manager: "ThemeManager | None" = None,
435
+ ) -> None:
436
+ if store is not None:
437
+ self._store = store
438
+ if preferences is not None:
439
+ self._preferences = preferences
440
+ if theme_manager is not None:
441
+ self._theme_manager = theme_manager
442
+
443
+ def theme_manager(self) -> "ThemeManager | None":
444
+ return self._theme_manager
445
+
446
+ def global_state(self, plugin_id: str) -> dict[str, object]:
447
+ if self._preferences is None:
448
+ return {}
449
+ state = self._preferences.plugin_state(str(plugin_id).strip())
450
+ return state if isinstance(state, dict) else {}
451
+
452
+ def set_global_state(self, plugin_id: str, values: dict[str, object]) -> None:
453
+ if self._preferences is None:
454
+ return
455
+ self._preferences.set_plugin_state(plugin_id, values)
456
+ self._preferences.save()
457
+
458
+ def global_value(self, plugin_id: str, key: str, default: object = None) -> object:
459
+ if self._preferences is None:
460
+ return default
461
+ return self._preferences.plugin_value(plugin_id, key, default)
462
+
463
+ def set_global_value(self, plugin_id: str, key: str, value: object) -> None:
464
+ if self._preferences is None:
465
+ return
466
+ self._preferences.set_plugin_value(plugin_id, key, value)
467
+ self._preferences.save()
468
+
469
+ def project_state(self, plugin_id: str) -> dict[str, object]:
470
+ if self._store is None:
471
+ return {}
472
+ state = self._store.plugin_state(str(plugin_id).strip())
473
+ return state if isinstance(state, dict) else {}
474
+
475
+ def set_project_state(self, plugin_id: str, values: dict[str, object]) -> None:
476
+ if self._store is None:
477
+ return
478
+ self._store.set_plugin_state(plugin_id, values)
479
+
480
+ def project_value(self, plugin_id: str, key: str, default: object = None) -> object:
481
+ if self._store is None:
482
+ return default
483
+ return self._store.plugin_value(plugin_id, key, default)
484
+
485
+ def set_project_value(self, plugin_id: str, key: str, value: object) -> None:
486
+ if self._store is None:
487
+ return
488
+ self._store.set_plugin_value(plugin_id, key, value)
489
+
490
+ def load_from_dirs(self, directories: list[Path]) -> None:
491
+ for directory in directories:
492
+ if directory not in self._plugin_dirs:
493
+ self._plugin_dirs.append(directory)
494
+ if not directory.exists() or not directory.is_dir():
495
+ continue
496
+ for path in sorted(directory.glob("*.py")):
497
+ if path.name.startswith("_"):
498
+ continue
499
+ self._load_plugin(path)
500
+
501
+ def loaded_plugins(self) -> list[LoadedPlugin]:
502
+ return list(self._plugins)
503
+
504
+ def load_errors(self) -> list[str]:
505
+ return list(self._load_errors)
506
+
507
+ def plugin_dirs(self) -> list[Path]:
508
+ return list(self._plugin_dirs)
509
+
510
+ def workspace_contributions(self) -> list[PluginWorkspaceContribution]:
511
+ return sorted(self._workspaces, key=lambda item: (item.order, item.label.lower(), item.workspace_id))
512
+
513
+ def panel_contributions(self, workspace_id: str | None = None) -> list[PluginPanelContribution]:
514
+ items = self._panels
515
+ if workspace_id is not None:
516
+ items = [item for item in items if item.workspace_id == workspace_id]
517
+ return sorted(items, key=lambda item: (item.order, item.title.lower(), item.panel_id))
518
+
519
+ def exporter_contributions(self) -> list[PluginExporterContribution]:
520
+ return sorted(self._exporters, key=lambda item: (item.order, item.label.lower(), item.exporter_id))
521
+
522
+ def keybinding_contributions(self) -> list[PluginKeybindingContribution]:
523
+ return sorted(self._keybindings, key=lambda item: (item.section.lower(), item.description.lower(), item.action))
524
+
525
+ def analyzer_contributions(self) -> list[PluginAnalyzerContribution]:
526
+ return sorted(self._analyzers, key=lambda item: (item.order, item.label.lower(), item.analyzer_id))
527
+
528
+ def metadata_contributions(self) -> list[PluginMetadataContribution]:
529
+ return sorted(self._metadata, key=lambda item: (item.order, item.label.lower(), item.metadata_id))
530
+
531
+ def setting_field_contributions(self) -> list[PluginSettingFieldContribution]:
532
+ return sorted(self._setting_fields, key=lambda item: (item.section.lower(), item.label.lower(), item.field_id))
533
+
534
+ def register_workspace(self, contribution: PluginWorkspaceContribution) -> None:
535
+ if not contribution.workspace_id:
536
+ raise ValueError("workspace id must not be empty")
537
+ if contribution.workspace_id in BUILTIN_WORKSPACE_IDS:
538
+ raise ValueError(f"workspace id {contribution.workspace_id!r} collides with a built-in workspace")
539
+ self._replace_or_append(
540
+ self._workspaces,
541
+ contribution,
542
+ lambda item: item.workspace_id == contribution.workspace_id,
543
+ )
544
+
545
+ def register_panel(self, contribution: PluginPanelContribution) -> None:
546
+ if not contribution.workspace_id or not contribution.panel_id:
547
+ raise ValueError("workspace id and panel id must not be empty")
548
+ self._replace_or_append(
549
+ self._panels,
550
+ contribution,
551
+ lambda item: (
552
+ item.workspace_id == contribution.workspace_id
553
+ and item.panel_id == contribution.panel_id
554
+ ),
555
+ )
556
+
557
+ def register_exporter(self, contribution: PluginExporterContribution) -> None:
558
+ if not contribution.exporter_id:
559
+ raise ValueError("exporter id must not be empty")
560
+ self._replace_or_append(
561
+ self._exporters,
562
+ contribution,
563
+ lambda item: item.exporter_id == contribution.exporter_id,
564
+ )
565
+
566
+ def register_keybinding(self, contribution: PluginKeybindingContribution) -> None:
567
+ if not contribution.action:
568
+ raise ValueError("keybinding action must not be empty")
569
+ self._replace_or_append(
570
+ self._keybindings,
571
+ contribution,
572
+ lambda item: item.action == contribution.action,
573
+ )
574
+
575
+ def register_analyzer(self, contribution: PluginAnalyzerContribution) -> None:
576
+ if not contribution.analyzer_id:
577
+ raise ValueError("analyzer id must not be empty")
578
+ self._replace_or_append(
579
+ self._analyzers,
580
+ contribution,
581
+ lambda item: item.analyzer_id == contribution.analyzer_id,
582
+ )
583
+
584
+ def register_metadata(self, contribution: PluginMetadataContribution) -> None:
585
+ if not contribution.metadata_id:
586
+ raise ValueError("metadata id must not be empty")
587
+ self._replace_or_append(
588
+ self._metadata,
589
+ contribution,
590
+ lambda item: item.metadata_id == contribution.metadata_id,
591
+ )
592
+
593
+ def register_setting_field(self, contribution: PluginSettingFieldContribution) -> None:
594
+ if not contribution.field_id:
595
+ raise ValueError("setting field id must not be empty")
596
+ self._replace_or_append(
597
+ self._setting_fields,
598
+ contribution,
599
+ lambda item: (
600
+ item.plugin_id == contribution.plugin_id
601
+ and item.field_id == contribution.field_id
602
+ ),
603
+ )
604
+
605
+ def before_request_forward(self, context: HookContext, request: "ParsedRequest") -> "ParsedRequest":
606
+ current = request
607
+ context.plugin_manager = self
608
+ for plugin in self._plugins:
609
+ hook = getattr(plugin.instance, "before_request_forward", None)
610
+ if hook is None:
611
+ continue
612
+ candidate = hook(context, current)
613
+ if candidate is not None:
614
+ current = candidate
615
+ return current
616
+
617
+ def on_response_received(
618
+ self,
619
+ context: HookContext,
620
+ request: "ParsedRequest",
621
+ response: "ParsedResponse",
622
+ ) -> None:
623
+ context.plugin_manager = self
624
+ for plugin in self._plugins:
625
+ hook = getattr(plugin.instance, "on_response_received", None)
626
+ if hook is None:
627
+ continue
628
+ hook(context, request, response)
629
+
630
+ def on_error(self, context: HookContext, error: Exception) -> None:
631
+ context.plugin_manager = self
632
+ for plugin in self._plugins:
633
+ hook = getattr(plugin.instance, "on_error", None)
634
+ if hook is None:
635
+ continue
636
+ hook(context, error)
637
+
638
+ def persist_hook_context(self, context: HookContext) -> None:
639
+ if self._store is None:
640
+ return
641
+ for plugin_id, metadata in context.metadata.items():
642
+ self._store.set_entry_plugin_metadata(context.entry_id, plugin_id, metadata)
643
+ for plugin_id, findings in context.findings.items():
644
+ self._store.set_entry_plugin_findings(context.entry_id, plugin_id, findings)
645
+
646
+ def _load_plugin(self, path: Path) -> None:
647
+ provisional_id = path.stem
648
+ api = PluginAPI(self, provisional_id)
649
+ try:
650
+ module = self._load_module(path)
651
+ plugin = self._instantiate_plugin(module, api)
652
+ final_id = str(getattr(plugin, "plugin_id", provisional_id)).strip() or provisional_id
653
+ if final_id != provisional_id:
654
+ api.set_plugin_id(final_id)
655
+ name = str(getattr(plugin, "name", final_id))
656
+ module_contribute = getattr(module, "contribute", None)
657
+ if callable(module_contribute):
658
+ self._call_plugin_factory(module_contribute, api)
659
+ contribute = getattr(plugin, "contribute", None)
660
+ if callable(contribute):
661
+ self._call_plugin_factory(contribute, api)
662
+ on_loaded = getattr(plugin, "on_loaded", None)
663
+ if callable(on_loaded):
664
+ on_loaded()
665
+ self._plugins.append(
666
+ LoadedPlugin(plugin_id=api.plugin_id, name=name, path=path, instance=plugin)
667
+ )
668
+ except Exception as exc:
669
+ self._load_errors.append(f"{path}: {exc}")
670
+
671
+ @staticmethod
672
+ def _load_module(path: Path) -> ModuleType:
673
+ spec = importlib.util.spec_from_file_location(f"hexproxy_plugin_{path.stem}", path)
674
+ if spec is None or spec.loader is None:
675
+ raise RuntimeError("unable to create import spec")
676
+ module = importlib.util.module_from_spec(spec)
677
+ spec.loader.exec_module(module)
678
+ return module
679
+
680
+ def _instantiate_plugin(self, module: ModuleType, api: PluginAPI) -> object:
681
+ register = getattr(module, "register", None)
682
+ if callable(register):
683
+ plugin = self._call_plugin_factory(register, api)
684
+ return module if plugin is None else plugin
685
+ plugin = getattr(module, "PLUGIN", None)
686
+ if plugin is not None:
687
+ return plugin
688
+ contribute = getattr(module, "contribute", None)
689
+ if callable(contribute):
690
+ self._call_plugin_factory(contribute, api)
691
+ return module
692
+ raise RuntimeError("plugin module must export register(api) / register(), PLUGIN, or contribute(api)")
693
+
694
+ @staticmethod
695
+ def _call_plugin_factory(factory: Callable[..., object], api: PluginAPI) -> object:
696
+ signature = inspect.signature(factory)
697
+ positional = [
698
+ parameter
699
+ for parameter in signature.parameters.values()
700
+ if parameter.kind in (
701
+ inspect.Parameter.POSITIONAL_ONLY,
702
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
703
+ )
704
+ ]
705
+ required = [
706
+ parameter
707
+ for parameter in positional
708
+ if parameter.default is inspect._empty
709
+ ]
710
+ if len(required) == 0:
711
+ return factory()
712
+ if len(required) == 1 and len(positional) == 1:
713
+ return factory(api)
714
+ raise RuntimeError("plugin factory must accept either zero arguments or a single PluginAPI")
715
+
716
+ @staticmethod
717
+ def _replace_or_append(items: list[Any], new_item: Any, matcher: Callable[[Any], bool]) -> None:
718
+ for index, existing in enumerate(items):
719
+ if matcher(existing):
720
+ items[index] = new_item
721
+ return
722
+ items.append(new_item)
723
+
724
+ def _reassign_plugin_id(self, old_id: str, new_id: str) -> None:
725
+ if old_id == new_id:
726
+ return
727
+ for collection_name in (
728
+ "_workspaces",
729
+ "_panels",
730
+ "_exporters",
731
+ "_keybindings",
732
+ "_analyzers",
733
+ "_metadata",
734
+ "_setting_fields",
735
+ ):
736
+ collection = getattr(self, collection_name)
737
+ for item in collection:
738
+ if getattr(item, "plugin_id", None) == old_id:
739
+ item.plugin_id = new_id