1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
from __future__ import annotations
import contextlib
import importlib.metadata
import logging
import threading
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Generic, TypeVar
from pygitweb.api.action import Action
from pygitweb.api.plugin import Plugin
from pygitweb.api.subpage import Subpage
from pygitweb.config import ACTIONS as BUILTIN_ACTIONS
logger = logging.getLogger(__name__)
SUBPAGE_GROUP = "pygitweb.subpages"
ACTION_GROUP = "pygitweb.actions"
_local = threading.local()
T = TypeVar("T", bound=Plugin)
def _plugin_id_stack() -> list[str]:
stack: list[str] | None = getattr(_local, "plugin_id_stack", None)
if stack is None:
stack = []
_local.plugin_id_stack = stack
return stack
def current_plugin_id() -> str | None:
stack = _plugin_id_stack()
return stack[-1] if stack else None
class _PluginLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
plugin_id = current_plugin_id()
if plugin_id is not None:
record.msg = f"{plugin_id}: {record.msg}"
return True
def _install_plugin_log_filter() -> None:
logging.getLogger("pygitweb").addFilter(_PluginLogFilter())
_install_plugin_log_filter()
@contextlib.contextmanager
def plugin_load_context(plugin_id: str):
stack = _plugin_id_stack()
stack.append(plugin_id)
try:
yield
finally:
if stack and stack[-1] == plugin_id:
stack.pop()
elif plugin_id in stack:
stack.remove(plugin_id)
def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
return importlib.metadata.entry_points().select(group=group)
@dataclass
class PluginHandle(Generic[T]):
plugin_id: str
_stack: contextlib.ExitStack
instance: T
def unload(self) -> None:
self._stack.close()
def _load_handle(
ep: importlib.metadata.EntryPoint,
expected_type: type[T],
) -> PluginHandle[T] | None:
stack = contextlib.ExitStack()
try:
stack.enter_context(plugin_load_context(ep.name))
loaded = ep.load()
except Exception:
stack.close()
logger.exception("pygitweb: failed to load entry point %r", ep.name)
return None
if not isinstance(loaded, type) or not issubclass(loaded, expected_type):
stack.close()
logger.error(
"pygitweb: entry point %r must be a %s subclass, got %r",
ep.name,
expected_type.__name__,
loaded,
)
return None
try:
instance = stack.enter_context(loaded())
except Exception:
stack.close()
logger.exception("pygitweb: failed to enter plugin context for %r", ep.name)
return None
return PluginHandle(plugin_id=ep.name, _stack=stack, instance=instance)
class PluginRegistry:
def __init__(self) -> None:
self._lock = threading.Lock()
self._action_handles: dict[str, PluginHandle[Action]] = {}
self._subpage_handles: list[PluginHandle[Subpage]] = []
def load_all(self) -> None:
with self._lock:
self._unload_locked()
self._load_actions_locked()
self._load_subpages_locked()
def unload_all(self) -> None:
with self._lock:
self._unload_locked()
def _unload_locked(self) -> None:
for handle in self._action_handles.values():
handle.unload()
for handle in self._subpage_handles:
handle.unload()
self._action_handles.clear()
self._subpage_handles.clear()
def _load_actions_locked(self) -> None:
for ep in _entry_points(ACTION_GROUP):
handle = _load_handle(ep, Action)
if handle is None:
continue
name = handle.instance.action_name()
if name in BUILTIN_ACTIONS:
logger.warning(
"pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
name,
ep.name,
)
handle.unload()
continue
if name in self._action_handles:
logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
handle.unload()
continue
self._action_handles[name] = handle
def _load_subpages_locked(self) -> None:
seen_names: set[str] = set()
for ep in _entry_points(SUBPAGE_GROUP):
handle = _load_handle(ep, Subpage)
if handle is None:
continue
key = handle.instance.subpage_name()
if key in seen_names:
logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
handle.unload()
continue
seen_names.add(key)
self._subpage_handles.append(handle)
@property
def actions(self) -> dict[str, Action]:
return {name: handle.instance for name, handle in self._action_handles.items()}
@property
def subpages(self) -> list[Subpage]:
return [handle.instance for handle in self._subpage_handles]
def dispatch_actions(self) -> frozenset[str]:
return frozenset(BUILTIN_ACTIONS) | frozenset(self._action_handles.keys())