updater-gitplucker 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.
gitplucker/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """gitplucker — modular, pluggable GitHub updater for Python-glued projects.
2
+
3
+ Public API (stable):
4
+
5
+ from gitplucker import (
6
+ Updater, UpdaterConfig, RepoSubscription,
7
+ Channel, ApplyStrategy, ConflictPolicy,
8
+ ManualTrigger, StartupTrigger, BackgroundTrigger,
9
+ )
10
+
11
+ See ``docs/INTEGRATION.md`` for a step-by-step wiring guide (written so an AI
12
+ assistant can drop this into an app unattended).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .config import ApplyStrategy, ConflictPolicy, RepoSubscription, UpdaterConfig
18
+ from .errors import (
19
+ ApplyError,
20
+ ConfigError,
21
+ GitHubAPIError,
22
+ GitpluckerError,
23
+ MergeConflictError,
24
+ RepoNotAllowedError,
25
+ SourceError,
26
+ )
27
+ from .merge import MergeResult, merge_text
28
+ from .models import (
29
+ ApplyResult,
30
+ Channel,
31
+ ChangeType,
32
+ DependencyChange,
33
+ FileChange,
34
+ UpdatePlan,
35
+ )
36
+ from .triggers import BackgroundTrigger, ManualTrigger, StartupTrigger
37
+ from .updater import Updater
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "Updater",
43
+ "UpdaterConfig",
44
+ "RepoSubscription",
45
+ "Channel",
46
+ "ApplyStrategy",
47
+ "ConflictPolicy",
48
+ "ChangeType",
49
+ "UpdatePlan",
50
+ "FileChange",
51
+ "DependencyChange",
52
+ "ApplyResult",
53
+ "ManualTrigger",
54
+ "StartupTrigger",
55
+ "BackgroundTrigger",
56
+ "merge_text",
57
+ "MergeResult",
58
+ "GitpluckerError",
59
+ "ConfigError",
60
+ "RepoNotAllowedError",
61
+ "SourceError",
62
+ "GitHubAPIError",
63
+ "MergeConflictError",
64
+ "ApplyError",
65
+ "__version__",
66
+ ]
@@ -0,0 +1,3 @@
1
+ from .github_api import GitHubClient, ReleaseInfo
2
+
3
+ __all__ = ["GitHubClient", "ReleaseInfo"]
@@ -0,0 +1,133 @@
1
+ """Zero-dependency GitHub REST client.
2
+
3
+ Uses only the stdlib (``urllib``) so the core library installs with no third
4
+ party packages. Every method enforces the repository allowlist: a repo that
5
+ was not explicitly permitted can never be contacted.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ from ..errors import GitHubAPIError, RepoNotAllowedError
18
+
19
+ ProgressCb = Callable[[int, int], None]
20
+
21
+
22
+ @dataclass
23
+ class ReleaseInfo:
24
+ tag: str
25
+ name: str
26
+ body: str
27
+ prerelease: bool
28
+ assets: list[dict] = field(default_factory=list) # each: {name, browser_download_url, url, size}
29
+ zipball_url: str = ""
30
+
31
+
32
+ class GitHubClient:
33
+ def __init__(
34
+ self,
35
+ allowed_repos: set[str] | list[str],
36
+ token: str | None = None,
37
+ api_base: str = "https://api.github.com",
38
+ ) -> None:
39
+ self._allowed = set(allowed_repos)
40
+ self._token = token
41
+ self._api = api_base.rstrip("/")
42
+
43
+ # -- allowlist gate ---------------------------------------------------
44
+ def _guard(self, repo: str) -> None:
45
+ if repo not in self._allowed:
46
+ raise RepoNotAllowedError(
47
+ f"repo {repo!r} is not allowlisted; refusing to contact GitHub."
48
+ )
49
+
50
+ def _headers(self, accept: str = "application/vnd.github+json") -> dict[str, str]:
51
+ h = {
52
+ "Accept": accept,
53
+ "User-Agent": "gitplucker/0.1",
54
+ "X-GitHub-Api-Version": "2022-11-28",
55
+ }
56
+ if self._token:
57
+ h["Authorization"] = f"Bearer {self._token}"
58
+ return h
59
+
60
+ def _get_json(self, url: str) -> dict | list:
61
+ req = urllib.request.Request(url, headers=self._headers())
62
+ try:
63
+ with urllib.request.urlopen(req) as resp:
64
+ return json.loads(resp.read().decode("utf-8"))
65
+ except urllib.error.HTTPError as e:
66
+ raise GitHubAPIError(f"GET {url} failed: {e.reason}", status=e.code) from e
67
+ except urllib.error.URLError as e:
68
+ raise GitHubAPIError(f"GET {url} failed: {e.reason}") from e
69
+
70
+ # -- API surface ------------------------------------------------------
71
+ def get_latest_release(self, repo: str, include_prerelease: bool = False) -> ReleaseInfo | None:
72
+ self._guard(repo)
73
+ if include_prerelease:
74
+ data = self._get_json(f"{self._api}/repos/{repo}/releases")
75
+ if not data:
76
+ return None
77
+ rel = data[0] # releases come newest-first
78
+ else:
79
+ try:
80
+ rel = self._get_json(f"{self._api}/repos/{repo}/releases/latest")
81
+ except GitHubAPIError as e:
82
+ if e.status == 404:
83
+ return None
84
+ raise
85
+ return ReleaseInfo(
86
+ tag=rel.get("tag_name", ""),
87
+ name=rel.get("name") or rel.get("tag_name", ""),
88
+ body=rel.get("body", "") or "",
89
+ prerelease=bool(rel.get("prerelease")),
90
+ assets=rel.get("assets", []) or [],
91
+ zipball_url=rel.get("zipball_url", ""),
92
+ )
93
+
94
+ def get_branch_head(self, repo: str, branch: str) -> tuple[str, str]:
95
+ """Return ``(sha, iso_date)`` of the branch tip."""
96
+ self._guard(repo)
97
+ data = self._get_json(f"{self._api}/repos/{repo}/branches/{branch}")
98
+ commit = data.get("commit", {})
99
+ sha = commit.get("sha", "")
100
+ date = (
101
+ commit.get("commit", {}).get("committer", {}).get("date", "")
102
+ or commit.get("commit", {}).get("author", {}).get("date", "")
103
+ )
104
+ return sha, date
105
+
106
+ def download(self, repo: str, url: str, dest: Path, progress: ProgressCb | None = None) -> Path:
107
+ """Download a URL (asset or zipball) to ``dest``, enforcing the allowlist."""
108
+ self._guard(repo)
109
+ # Asset API URLs need the octet-stream Accept header to get the binary.
110
+ accept = "application/octet-stream" if "/releases/assets/" in url else "*/*"
111
+ req = urllib.request.Request(url, headers=self._headers(accept))
112
+ dest.parent.mkdir(parents=True, exist_ok=True)
113
+ try:
114
+ with urllib.request.urlopen(req) as resp, open(dest, "wb") as fh:
115
+ total = int(resp.headers.get("Content-Length") or 0)
116
+ received = 0
117
+ while True:
118
+ chunk = resp.read(65536)
119
+ if not chunk:
120
+ break
121
+ fh.write(chunk)
122
+ received += len(chunk)
123
+ if progress:
124
+ progress(received, total)
125
+ except urllib.error.HTTPError as e:
126
+ raise GitHubAPIError(f"download {url} failed: {e.reason}", status=e.code) from e
127
+ except urllib.error.URLError as e:
128
+ raise GitHubAPIError(f"download {url} failed: {e.reason}") from e
129
+ return dest
130
+
131
+ def zipball_url(self, repo: str, ref: str) -> str:
132
+ self._guard(repo)
133
+ return f"{self._api}/repos/{repo}/zipball/{ref}"
gitplucker/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """Configuration objects — the whole behaviour of the updater is data-driven.
2
+
3
+ A host constructs an :class:`UpdaterConfig`, passes it to
4
+ :class:`~gitplucker.updater.Updater`, and never has to subclass anything.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from .errors import ConfigError
13
+ from .models import Channel
14
+
15
+
16
+ class ConflictPolicy(str):
17
+ MARK = "mark" # write git-style conflict markers, keep going
18
+ LOCAL = "local" # keep the local version of a conflicted file
19
+ REMOTE = "remote" # take the remote version of a conflicted file
20
+ ABORT = "abort" # raise MergeConflictError
21
+
22
+
23
+ class ApplyStrategy(str):
24
+ WHOLE_APP = "whole_app" # replace the tracked tree, backup + rollback
25
+ SELECTIVE = "selective" # only paths matching include_globs
26
+ PACKAGE = "package" # pip-install the downloaded wheel/sdist
27
+
28
+
29
+ @dataclass
30
+ class RepoSubscription:
31
+ """One repo the app follows, on one or more branches, via one channel."""
32
+
33
+ repo: str # "owner/name"
34
+ branches: list[str] = field(default_factory=lambda: ["main"])
35
+ channel: Channel = Channel.PYTHON_SOURCE
36
+ # RELEASE channel: glob picking which asset to download (falls back to zipball).
37
+ asset_pattern: str = "*.zip"
38
+ # Only these paths (relative, glob) are considered part of the app payload.
39
+ include_globs: list[str] = field(default_factory=lambda: ["**/*"])
40
+ exclude_globs: list[str] = field(
41
+ default_factory=lambda: [
42
+ "**/.git/**", "**/__pycache__/**", "**/*.pyc",
43
+ "**/.gitplucker/**", "**/.venv/**", "**/node_modules/**",
44
+ ]
45
+ )
46
+ # Subfolder inside the repo that maps to install_root ("" == repo root).
47
+ source_subdir: str = ""
48
+
49
+ def __post_init__(self) -> None:
50
+ if isinstance(self.channel, str):
51
+ self.channel = Channel(self.channel)
52
+ if self.repo.count("/") != 1:
53
+ raise ConfigError(f"repo must be 'owner/name', got {self.repo!r}")
54
+
55
+
56
+ @dataclass
57
+ class UpdaterConfig:
58
+ """Top-level configuration.
59
+
60
+ ``allowed_repos`` is the security allowlist: any subscription (or ad-hoc
61
+ request) whose repo is not listed here is rejected before any network call.
62
+ """
63
+
64
+ install_root: Path
65
+ subscriptions: list[RepoSubscription] = field(default_factory=list)
66
+ allowed_repos: list[str] = field(default_factory=list)
67
+
68
+ token: str | None = None # GitHub PAT for private repos / rate limits
69
+ api_base: str = "https://api.github.com"
70
+
71
+ apply_strategy: str = ApplyStrategy.WHOLE_APP
72
+ selective_globs: list[str] = field(default_factory=list) # used by SELECTIVE
73
+
74
+ merge: bool = True # 3-way merge on python-source
75
+ conflict_policy: str = ConflictPolicy.MARK
76
+ auto_install_deps: bool = True
77
+ requirements_file: str | None = "requirements.txt" # relative to install_root
78
+ backup: bool = True
79
+
80
+ # Where gitplucker keeps its state (base snapshots, version manifest, backups).
81
+ state_dir: Path | None = None
82
+
83
+ def __post_init__(self) -> None:
84
+ self.install_root = Path(self.install_root)
85
+ if self.state_dir is None:
86
+ self.state_dir = self.install_root / ".gitplucker"
87
+ self.state_dir = Path(self.state_dir)
88
+
89
+ allowed = set(self.allowed_repos)
90
+ # A subscription implicitly requires its repo to be allowlisted.
91
+ for sub in self.subscriptions:
92
+ if sub.repo not in allowed:
93
+ raise ConfigError(
94
+ f"subscription repo {sub.repo!r} is not in allowed_repos "
95
+ f"{sorted(allowed)!r}; add it explicitly to permit updates."
96
+ )
97
+
98
+ def is_allowed(self, repo: str) -> bool:
99
+ return repo in set(self.allowed_repos)
100
+
101
+ def subscription_for(self, repo: str, branch: str | None = None) -> RepoSubscription | None:
102
+ for sub in self.subscriptions:
103
+ if sub.repo == repo and (branch is None or branch in sub.branches):
104
+ return sub
105
+ return None
@@ -0,0 +1,13 @@
1
+ from .python_deps import (
2
+ scan_imports,
3
+ resolve_dependencies,
4
+ install_requirements,
5
+ PACKAGE_ALIASES,
6
+ )
7
+
8
+ __all__ = [
9
+ "scan_imports",
10
+ "resolve_dependencies",
11
+ "install_requirements",
12
+ "PACKAGE_ALIASES",
13
+ ]
@@ -0,0 +1,211 @@
1
+ """Python dependency auto-detection and installation.
2
+
3
+ Flow for the ``python-source`` channel:
4
+
5
+ 1. Parse every ``.py`` file in the incoming payload with :mod:`ast`.
6
+ 2. Collect top-level imported module names.
7
+ 3. Drop the standard library and the app's own local packages.
8
+ 4. Whatever's left that isn't importable / installed is surfaced as a
9
+ :class:`~gitplucker.models.DependencyChange` — shown in the update plan and,
10
+ if ``auto_install_deps`` is on, ``pip install``-ed on apply.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import importlib.util
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ..models import DependencyChange
22
+
23
+ try:
24
+ from importlib import metadata as _im
25
+ except ImportError: # pragma: no cover
26
+ import importlib_metadata as _im # type: ignore
27
+
28
+ # Import name -> pip package name, for the common mismatches.
29
+ PACKAGE_ALIASES: dict[str, str] = {
30
+ "cv2": "opencv-python",
31
+ "PIL": "Pillow",
32
+ "yaml": "PyYAML",
33
+ "bs4": "beautifulsoup4",
34
+ "serial": "pyserial",
35
+ "dotenv": "python-dotenv",
36
+ "sklearn": "scikit-learn",
37
+ "skimage": "scikit-image",
38
+ "google": "google-api-python-client",
39
+ "OpenGL": "PyOpenGL",
40
+ "win32api": "pywin32",
41
+ "win32con": "pywin32",
42
+ "win32gui": "pywin32",
43
+ "wx": "wxPython",
44
+ "Crypto": "pycryptodome",
45
+ "usb": "pyusb",
46
+ "zmq": "pyzmq",
47
+ }
48
+
49
+ _STDLIB = set(getattr(sys, "stdlib_module_names", set())) | {"__future__"}
50
+
51
+ # Source-layout / directory-convention names that show up as dangling imports
52
+ # (e.g. code meant to run from a parent dir with ``src/`` on sys.path) but must
53
+ # NEVER be auto-installed — real PyPI packages by these names are never intended.
54
+ _NON_PACKAGE_NAMES = {
55
+ "src", "test", "tests", "docs", "doc", "examples", "example",
56
+ "scripts", "build", "dist",
57
+ }
58
+
59
+
60
+ def _iter_py_files(root: Path) -> list[Path]:
61
+ skip = {".git", "__pycache__", ".gitplucker", ".venv", "venv", "node_modules"}
62
+ files: list[Path] = []
63
+ for p in root.rglob("*.py"):
64
+ if any(part in skip for part in p.parts):
65
+ continue
66
+ files.append(p)
67
+ return files
68
+
69
+
70
+ def _local_top_levels(root: Path) -> set[str]:
71
+ """Names that resolve to something *inside* the app, so must never be
72
+ mistaken for a PyPI dependency.
73
+
74
+ Import scanning is inherently noisy (dangling/dev-only imports, code that
75
+ imports a sibling package by name), and the dangerous failure mode is
76
+ auto-``pip install``-ing a garbage package that happens to share the name.
77
+ So this is deliberately permissive: a name is treated as local if anywhere
78
+ in the payload there is a directory or a ``<name>.py`` with that name — not
79
+ only at the repo root.
80
+ """
81
+ local: set[str] = set()
82
+ skip = {".git", "__pycache__", ".gitplucker", ".venv", "venv", "node_modules"}
83
+ for p in root.rglob("*"):
84
+ if any(part in skip for part in p.relative_to(root).parts):
85
+ continue
86
+ if p.is_dir():
87
+ local.add(p.name) # any dir name (package or namespace dir)
88
+ elif p.suffix == ".py":
89
+ local.add(p.stem) # any module file, at any depth
90
+ return local
91
+
92
+
93
+ def scan_imports(root: Path) -> dict[str, str]:
94
+ """Return ``{top_level_module: first_file_relpath}`` for all imports found."""
95
+ found: dict[str, str] = {}
96
+ root = Path(root)
97
+ for py in _iter_py_files(root):
98
+ try:
99
+ tree = ast.parse(py.read_text(encoding="utf-8", errors="replace"), filename=str(py))
100
+ except SyntaxError:
101
+ continue
102
+ rel = py.relative_to(root).as_posix()
103
+ for node in ast.walk(tree):
104
+ if isinstance(node, ast.Import):
105
+ for alias in node.names:
106
+ top = alias.name.split(".")[0]
107
+ found.setdefault(top, rel)
108
+ elif isinstance(node, ast.ImportFrom):
109
+ if node.level and node.level > 0:
110
+ continue # relative import -> local, never a dependency
111
+ if node.module:
112
+ top = node.module.split(".")[0]
113
+ found.setdefault(top, rel)
114
+ return found
115
+
116
+
117
+ def _installed_packages() -> set[str]:
118
+ names: set[str] = set()
119
+ try:
120
+ for dist in _im.distributions():
121
+ name = (dist.metadata["Name"] or "").lower()
122
+ if name:
123
+ names.add(name)
124
+ except Exception:
125
+ pass
126
+ return names
127
+
128
+
129
+ def _is_satisfied(module: str, package: str, installed: set[str]) -> bool:
130
+ if package.lower() in installed:
131
+ return True
132
+ try:
133
+ return importlib.util.find_spec(module) is not None
134
+ except (ImportError, ValueError, ModuleNotFoundError):
135
+ return False
136
+
137
+
138
+ def _parse_requirements(path: Path) -> dict[str, str]:
139
+ """Map lowercased package name -> full requirement spec from a requirements file."""
140
+ specs: dict[str, str] = {}
141
+ if not path.exists():
142
+ return specs
143
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
144
+ line = line.strip()
145
+ if not line or line.startswith("#") or line.startswith("-"):
146
+ continue
147
+ name = line
148
+ for sep in ("==", ">=", "<=", "~=", ">", "<", "!=", "[", ";", " "):
149
+ idx = line.find(sep)
150
+ if idx > 0:
151
+ name = line[:idx]
152
+ break
153
+ specs[name.strip().lower()] = line
154
+ return specs
155
+
156
+
157
+ def resolve_dependencies(
158
+ payload_root: Path,
159
+ requirements_path: Path | None = None,
160
+ known_modules: set[str] | None = None,
161
+ ) -> list[DependencyChange]:
162
+ """Compute the dependencies the incoming payload needs but the env lacks.
163
+
164
+ ``known_modules`` is the set seen in the *previous* applied version; anything
165
+ outside it is flagged ``is_new`` (a genuinely newly-added module).
166
+ """
167
+ payload_root = Path(payload_root)
168
+ imports = scan_imports(payload_root)
169
+ local = _local_top_levels(payload_root)
170
+ installed = _installed_packages()
171
+ req_specs = _parse_requirements(requirements_path) if requirements_path else {}
172
+ known = known_modules or set()
173
+
174
+ changes: list[DependencyChange] = []
175
+ for module, first_file in sorted(imports.items()):
176
+ if module in _STDLIB or module in local or module in _NON_PACKAGE_NAMES:
177
+ continue
178
+ package = PACKAGE_ALIASES.get(module, module)
179
+ if _is_satisfied(module, package, installed):
180
+ continue
181
+ spec = ""
182
+ req = req_specs.get(package.lower())
183
+ if req and any(op in req for op in ("==", ">=", "<=", "~=", ">", "<", "!=")):
184
+ # Preserve pinned version from requirements.txt.
185
+ spec = req[len(package):].split(";")[0].strip()
186
+ changes.append(
187
+ DependencyChange(
188
+ module=module,
189
+ package=package,
190
+ spec=spec,
191
+ is_new=module not in known,
192
+ reason=f"imported in {first_file}",
193
+ )
194
+ )
195
+ return changes
196
+
197
+
198
+ def install_requirements(
199
+ requirements: list[str],
200
+ python: str | None = None,
201
+ extra_pip_args: list[str] | None = None,
202
+ ) -> tuple[bool, str]:
203
+ """``pip install`` the given requirement strings. Returns ``(ok, output)``."""
204
+ if not requirements:
205
+ return True, "nothing to install"
206
+ cmd = [python or sys.executable, "-m", "pip", "install", *(extra_pip_args or []), *requirements]
207
+ try:
208
+ proc = subprocess.run(cmd, capture_output=True, text=True)
209
+ except Exception as e: # pragma: no cover
210
+ return False, f"failed to launch pip: {e}"
211
+ return proc.returncode == 0, (proc.stdout + proc.stderr)
gitplucker/errors.py ADDED
@@ -0,0 +1,43 @@
1
+ """Exception hierarchy for gitplucker.
2
+
3
+ Every error raised by the library derives from :class:`GitpluckerError`, so a
4
+ host application can catch the whole surface with a single ``except``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class GitpluckerError(Exception):
11
+ """Base class for all gitplucker errors."""
12
+
13
+
14
+ class ConfigError(GitpluckerError):
15
+ """The :class:`~gitplucker.config.UpdaterConfig` is invalid or inconsistent."""
16
+
17
+
18
+ class RepoNotAllowedError(GitpluckerError):
19
+ """A repository was requested that is not in ``allowed_repos``.
20
+
21
+ This is a hard security boundary: gitplucker will never fetch, download, or
22
+ apply anything from a repo the host did not explicitly permit.
23
+ """
24
+
25
+
26
+ class SourceError(GitpluckerError):
27
+ """An update source failed to produce a usable payload."""
28
+
29
+
30
+ class GitHubAPIError(SourceError):
31
+ """The GitHub REST API returned an error or unexpected response."""
32
+
33
+ def __init__(self, message: str, status: int | None = None) -> None:
34
+ super().__init__(message)
35
+ self.status = status
36
+
37
+
38
+ class MergeConflictError(GitpluckerError):
39
+ """A merge produced conflicts and the policy was to abort."""
40
+
41
+
42
+ class ApplyError(GitpluckerError):
43
+ """Applying an update failed; a rollback may have been attempted."""
gitplucker/events.py ADDED
@@ -0,0 +1,51 @@
1
+ """Minimal synchronous event bus so hosts can hook into progress/prompts.
2
+
3
+ The library never blocks on user input directly; instead it emits events and
4
+ (optionally) asks a decision callback. This keeps it usable from a Qt GUI, a
5
+ CLI, or a headless service alike.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable
12
+
13
+
14
+ # Well-known event names (documented for integrators). Emitting arbitrary names
15
+ # is fine too; these are just the ones the built-in flow raises.
16
+ CHECK_START = "check.start"
17
+ CHECK_DONE = "check.done"
18
+ DOWNLOAD_PROGRESS = "download.progress" # payload: {repo, received, total}
19
+ MERGE_FILE = "merge.file" # payload: {path, conflict}
20
+ CONFLICT = "conflict" # payload: {path, lines}
21
+ DEP_DETECTED = "dep.detected" # payload: {requirement}
22
+ DEP_INSTALL = "dep.install" # payload: {requirement, ok}
23
+ APPLY_FILE = "apply.file" # payload: {path, change}
24
+ BACKUP = "backup" # payload: {path}
25
+ ROLLBACK = "rollback" # payload: {path}
26
+ APPLY_DONE = "apply.done" # payload: {success}
27
+
28
+
29
+ Listener = Callable[[str, dict], None]
30
+
31
+
32
+ @dataclass
33
+ class EventEmitter:
34
+ _listeners: list[Listener] = field(default_factory=list)
35
+
36
+ def on(self, listener: Listener) -> Listener:
37
+ """Register a ``listener(event_name, payload)``. Returns it for easy removal."""
38
+ self._listeners.append(listener)
39
+ return listener
40
+
41
+ def off(self, listener: Listener) -> None:
42
+ if listener in self._listeners:
43
+ self._listeners.remove(listener)
44
+
45
+ def emit(self, event: str, **payload: Any) -> None:
46
+ for listener in list(self._listeners):
47
+ try:
48
+ listener(event, payload)
49
+ except Exception:
50
+ # A misbehaving listener must never break an update.
51
+ pass