forgeoptimizer 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.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,90 @@
1
+ """TOML writer helper to update ForgeCLI config settings on the fly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try:
10
+ import tomllib # py3.11+
11
+ except ModuleNotFoundError: # pragma: no cover
12
+ import tomli as tomllib # type: ignore[no-redef,import-not-found]
13
+
14
+
15
+ def dump_toml(data: dict[str, Any]) -> str:
16
+ """Serialize a dictionary to a basic TOML string (handles flat tables and primitive values)."""
17
+ lines = []
18
+ # Write top-level key-values first
19
+ for k, v in sorted(data.items()):
20
+ if not isinstance(v, dict):
21
+ if isinstance(v, bool):
22
+ lines.append(f"{k} = {str(v).lower()}")
23
+ elif isinstance(v, (int, float)):
24
+ lines.append(f"{k} = {v}")
25
+ elif isinstance(v, str):
26
+ lines.append(f'{k} = "{v}"')
27
+ elif isinstance(v, list):
28
+ items = ", ".join(f'"{x}"' if isinstance(x, str) else str(x) for x in v)
29
+ lines.append(f"{k} = [{items}]")
30
+
31
+ # Write tables
32
+ for section_name, section_val in sorted(data.items()):
33
+ if isinstance(section_val, dict):
34
+ lines.append(f"\n[{section_name}]")
35
+ for k, v in sorted(section_val.items()):
36
+ if isinstance(v, bool):
37
+ lines.append(f"{k} = {str(v).lower()}")
38
+ elif isinstance(v, (int, float)):
39
+ lines.append(f"{k} = {v}")
40
+ elif isinstance(v, str):
41
+ lines.append(f'{k} = "{v}"')
42
+ elif isinstance(v, list):
43
+ items = ", ".join(f'"{x}"' if isinstance(x, str) else str(x) for x in v)
44
+ lines.append(f"{k} = [{items}]")
45
+
46
+ return "\n".join(lines).strip() + "\n"
47
+
48
+
49
+ def update_config(
50
+ default_provider: str | None = None,
51
+ default_model: str | None = None,
52
+ clear_provider: bool = False,
53
+ clear_model: bool = False,
54
+ ) -> Path:
55
+ """Update defaults in the active forgecli.toml configuration file."""
56
+ from forgecli.config.loader import ConfigLoader
57
+
58
+ loader = ConfigLoader()
59
+ # Find existing writeable candidate config file
60
+ candidates = loader._candidate_paths()
61
+ target_path = None
62
+ for p in candidates:
63
+ if p.exists() and p.name != "pyproject.toml":
64
+ target_path = p
65
+ break
66
+
67
+ if not target_path:
68
+ target_path = Path("./forgecli.toml")
69
+
70
+ data: dict[str, Any] = {}
71
+ if target_path.exists():
72
+ with contextlib.suppress(Exception):
73
+ data = tomllib.loads(target_path.read_text(encoding="utf-8"))
74
+
75
+ if "providers" not in data:
76
+ data["providers"] = {}
77
+
78
+ if clear_provider:
79
+ data["providers"].pop("default", None)
80
+ elif default_provider is not None:
81
+ data["providers"]["default"] = default_provider
82
+
83
+ if clear_model:
84
+ data["providers"].pop("default_model", None)
85
+ elif default_model is not None:
86
+ data["providers"]["default_model"] = default_model
87
+
88
+ content = dump_toml(data)
89
+ target_path.write_text(content, encoding="utf-8")
90
+ return target_path
@@ -0,0 +1,35 @@
1
+ """Core orchestration primitives: app context, DI container, events."""
2
+
3
+ from forgecli.core.container import Container
4
+ from forgecli.core.context import AppContext
5
+ from forgecli.core.errors import (
6
+ ConfigError,
7
+ ForgeCLIError,
8
+ GitError,
9
+ PipelineError,
10
+ PluginError,
11
+ ProviderError,
12
+ )
13
+ from forgecli.core.events import Event, EventBus
14
+ from forgecli.core.logging import configure_logging, get_logger
15
+ from forgecli.core.plugins import Plugin, discover_plugins, install_plugins
16
+ from forgecli.core.service import Service
17
+
18
+ __all__ = [
19
+ "AppContext",
20
+ "ConfigError",
21
+ "Container",
22
+ "Event",
23
+ "EventBus",
24
+ "ForgeCLIError",
25
+ "GitError",
26
+ "PipelineError",
27
+ "Plugin",
28
+ "PluginError",
29
+ "ProviderError",
30
+ "Service",
31
+ "configure_logging",
32
+ "discover_plugins",
33
+ "get_logger",
34
+ "install_plugins",
35
+ ]
@@ -0,0 +1,68 @@
1
+ """Lightweight dependency-injection container."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class Container:
12
+ """A minimal service container with factory and singleton registrations.
13
+
14
+ The container intentionally avoids any global state; an instance is
15
+ owned by :class:`forgecli.core.context.AppContext` and created per process.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ self._factories: dict[type, Callable[[Container], Any]] = {}
20
+ self._singletons: dict[type, Any] = {}
21
+ self._singleton_flags: dict[type, bool] = {}
22
+
23
+ def register(
24
+ self,
25
+ interface: type[T],
26
+ factory: Callable[[Container], T],
27
+ *,
28
+ singleton: bool = True,
29
+ ) -> None:
30
+ """Register a factory for ``interface``.
31
+
32
+ When ``singleton`` is True, the first call to :meth:`resolve` will
33
+ cache the result and return it for all subsequent calls.
34
+ """
35
+ self._factories[interface] = factory
36
+ self._singleton_flags[interface] = singleton
37
+ if not singleton:
38
+ self._singletons.pop(interface, None)
39
+
40
+ def register_instance(self, interface: type[T], instance: T) -> None:
41
+ """Bind a pre-constructed instance to ``interface``."""
42
+ self._factories[interface] = lambda _c: instance
43
+ self._singletons[interface] = instance
44
+ self._singleton_flags[interface] = True
45
+
46
+ def resolve(self, interface: type[T]) -> T:
47
+ """Resolve an instance for ``interface``."""
48
+ if interface in self._singletons:
49
+ return self._singletons[interface] # type: ignore[return-value]
50
+ if interface not in self._factories:
51
+ raise KeyError(f"No registration for {interface!r}")
52
+ instance = self._factories[interface](self)
53
+ if self._is_singleton(interface):
54
+ self._singletons[interface] = instance
55
+ return instance # type: ignore[return-value]
56
+
57
+ def _is_singleton(self, interface: type) -> bool:
58
+ """Return True if ``interface`` was registered as a singleton."""
59
+ return self._singleton_flags.get(interface, True)
60
+
61
+ def has(self, interface: type) -> bool:
62
+ return interface in self._factories or interface in self._singletons
63
+
64
+ def clear(self) -> None:
65
+ """Drop all registrations and cached singletons."""
66
+ self._factories.clear()
67
+ self._singletons.clear()
68
+ self._singleton_flags.clear()
@@ -0,0 +1,54 @@
1
+ """Application context: shared state object passed across the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from forgecli.core.container import Container
10
+ from forgecli.core.events import EventBus
11
+ from forgecli.utils.paths import ProjectPaths
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - typing only
14
+ from forgecli.config.loader import ConfigLoader
15
+ from forgecli.config.settings import ForgeSettings
16
+
17
+
18
+ @dataclass
19
+ class AppContext:
20
+ """Shared, mutable context for one CLI invocation.
21
+
22
+ The context is created in :mod:`forgecli.cli.main` and explicitly
23
+ passed to services that need it. It owns the configuration loader,
24
+ the DI container, the event bus, and resolved filesystem paths.
25
+ """
26
+
27
+ paths: ProjectPaths
28
+ loader: ConfigLoader
29
+ settings: ForgeSettings | None = None
30
+ container: Container = field(default_factory=Container)
31
+ event_bus: EventBus = field(default_factory=EventBus)
32
+ extras: dict[str, Any] = field(default_factory=dict)
33
+
34
+ def resolve_settings(self, *, force: bool = False) -> ForgeSettings:
35
+ """Load (or return cached) settings."""
36
+ if self.settings is None or force:
37
+ self.settings = self.loader.load(force=force)
38
+ return self.settings
39
+
40
+ def with_overrides(self, **values: Any) -> AppContext:
41
+ """Return a shallow copy with extra values merged into ``extras``."""
42
+ new = AppContext(
43
+ paths=self.paths,
44
+ loader=self.loader,
45
+ settings=self.settings,
46
+ container=self.container,
47
+ event_bus=self.event_bus,
48
+ extras={**self.extras, **values},
49
+ )
50
+ return new
51
+
52
+ @property
53
+ def cwd(self) -> Path:
54
+ return self.paths.cwd
@@ -0,0 +1,114 @@
1
+ """Secure storage for API keys with OS keychain (keyring) and encrypted fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import getpass
8
+ import hashlib
9
+ import json
10
+ import sys
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ import keyring
15
+ from cryptography.fernet import Fernet
16
+
17
+ from forgecli.utils.paths import ProjectPaths
18
+
19
+ KEYRING_SERVICE = "forgectx"
20
+
21
+
22
+ def _get_credentials_file() -> Path:
23
+ paths = ProjectPaths.from_env()
24
+ paths.config_dir.mkdir(parents=True, exist_ok=True)
25
+ return paths.config_dir / "credentials.json"
26
+
27
+
28
+ def _get_encryption_key() -> bytes:
29
+ try:
30
+ host_info = f"{uuid.getnode()}:{getpass.getuser()}:{sys.platform}"
31
+ except Exception:
32
+ host_info = "fallback-forgectx-key-salt"
33
+ key_hash = hashlib.sha256(host_info.encode("utf-8")).digest()
34
+ return base64.urlsafe_b64encode(key_hash)
35
+
36
+
37
+ def _read_encrypted_file() -> dict[str, str]:
38
+ path = _get_credentials_file()
39
+ if not path.exists():
40
+ return {}
41
+ try:
42
+ raw_data = path.read_bytes()
43
+ fernet = Fernet(_get_encryption_key())
44
+ decrypted = fernet.decrypt(raw_data)
45
+ return json.loads(decrypted.decode("utf-8"))
46
+ except Exception:
47
+ return {}
48
+
49
+
50
+ def _write_encrypted_file(data: dict[str, str]) -> None:
51
+ path = _get_credentials_file()
52
+ fernet = Fernet(_get_encryption_key())
53
+ encrypted = fernet.encrypt(json.dumps(data).encode("utf-8"))
54
+ path.write_bytes(encrypted)
55
+ with contextlib.suppress(OSError):
56
+ path.chmod(0o600)
57
+
58
+
59
+ def get_api_key(provider: str) -> str | None:
60
+ """Retrieve the API key for a provider."""
61
+ provider = provider.lower().strip()
62
+ try:
63
+ val = keyring.get_password(KEYRING_SERVICE, provider)
64
+ if val is not None:
65
+ return val
66
+ except Exception:
67
+ pass
68
+ data = _read_encrypted_file()
69
+ return data.get(provider)
70
+
71
+
72
+ def set_api_key(provider: str, api_key: str) -> None:
73
+ """Store an API key in the OS keychain, falling back to encrypted disk storage."""
74
+ provider = provider.lower().strip()
75
+ api_key = api_key.strip()
76
+ if not provider or not api_key:
77
+ raise ValueError("provider and api_key are required")
78
+ try:
79
+ keyring.set_password(KEYRING_SERVICE, provider, api_key)
80
+ return
81
+ except Exception:
82
+ data = _read_encrypted_file()
83
+ data[provider] = api_key
84
+ _write_encrypted_file(data)
85
+
86
+
87
+ def delete_api_key(provider: str) -> None:
88
+ """Remove a stored API key."""
89
+ provider = provider.lower().strip()
90
+ with contextlib.suppress(Exception):
91
+ keyring.delete_password(KEYRING_SERVICE, provider)
92
+ data = _read_encrypted_file()
93
+ if provider in data:
94
+ del data[provider]
95
+ _write_encrypted_file(data)
96
+
97
+
98
+ def list_stored_providers() -> list[str]:
99
+ """Return provider names with stored credentials."""
100
+ providers = set(_read_encrypted_file())
101
+ for name in (
102
+ "openai",
103
+ "anthropic",
104
+ "google",
105
+ "openrouter",
106
+ "groq",
107
+ "together",
108
+ ):
109
+ try:
110
+ if keyring.get_password(KEYRING_SERVICE, name):
111
+ providers.add(name)
112
+ except Exception:
113
+ continue
114
+ return sorted(providers)
@@ -0,0 +1,27 @@
1
+ """Typed exception hierarchy for ForgeCLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ForgeCLIError(Exception):
7
+ """Base class for all ForgeCLI-specific errors."""
8
+
9
+
10
+ class ConfigError(ForgeCLIError):
11
+ """Raised when configuration cannot be loaded or validated."""
12
+
13
+
14
+ class ProviderError(ForgeCLIError):
15
+ """Raised on AI provider failures (network, auth, schema)."""
16
+
17
+
18
+ class GitError(ForgeCLIError):
19
+ """Raised on Git-related failures (missing repo, bad ref, etc)."""
20
+
21
+
22
+ class PluginError(ForgeCLIError):
23
+ """Raised on plugin discovery or lifecycle failures."""
24
+
25
+
26
+ class PipelineError(ForgeCLIError):
27
+ """Raised when a builder/review/planner pipeline cannot complete."""
@@ -0,0 +1,67 @@
1
+ """A tiny pub/sub event bus used for decoupled logging and lifecycle hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ Subscriber = Callable[["Event"], "None | Awaitable[None]"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Event:
17
+ """An immutable event payload."""
18
+
19
+ name: str
20
+ payload: dict[str, Any] = field(default_factory=dict)
21
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
22
+
23
+
24
+ class EventBus:
25
+ """Asynchronous-first pub/sub bus.
26
+
27
+ Synchronous subscribers are invoked inline; async subscribers are
28
+ scheduled via :func:`asyncio.create_task`. The bus is intentionally
29
+ simple: subscribers cannot block the publisher and ordering is
30
+ best-effort.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ self._subscribers: dict[str, list[Subscriber]] = {}
35
+ self._pending_tasks: set[asyncio.Task[Any]] = set()
36
+
37
+ def subscribe(self, name: str, callback: Subscriber) -> None:
38
+ """Register ``callback`` for events named ``name``."""
39
+ self._subscribers.setdefault(name, []).append(callback)
40
+
41
+ def unsubscribe(self, name: str, callback: Subscriber) -> None:
42
+ bucket = self._subscribers.get(name)
43
+ if not bucket:
44
+ return
45
+ with contextlib.suppress(ValueError):
46
+ bucket.remove(callback)
47
+
48
+ async def publish(self, name: str, **payload: Any) -> None:
49
+ """Publish an event, dispatching to all registered subscribers."""
50
+ event = Event(name=name, payload=dict(payload))
51
+ for callback in list(self._subscribers.get(name, ())):
52
+ result = callback(event)
53
+ if asyncio.iscoroutine(result):
54
+ await result
55
+
56
+ def emit(self, name: str, **payload: Any) -> None:
57
+ """Synchronous variant of :meth:`publish`; async subs are not awaited."""
58
+ event = Event(name=name, payload=dict(payload))
59
+ for callback in list(self._subscribers.get(name, ())):
60
+ result = callback(event)
61
+ if asyncio.iscoroutine(result):
62
+ # Fire-and-forget; require an event loop to be running.
63
+ with contextlib.suppress(RuntimeError):
64
+ loop = asyncio.get_running_loop()
65
+ task = loop.create_task(result)
66
+ self._pending_tasks.add(task)
67
+ task.add_done_callback(self._pending_tasks.discard)
@@ -0,0 +1,47 @@
1
+ """Logging configuration built on the standard library ``logging`` package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Final
8
+
9
+ _LOG_FORMAT: Final[str] = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
10
+ _configured = False
11
+
12
+
13
+ def configure_logging(level: str = "INFO") -> None:
14
+ """Configure root logging once for the lifetime of the process."""
15
+ global _configured
16
+ if _configured:
17
+ new_level = _coerce_level(level)
18
+ if logging.getLogger().level != logging.DEBUG or new_level == logging.DEBUG:
19
+ logging.getLogger().setLevel(new_level)
20
+ return
21
+
22
+ handler = logging.StreamHandler(stream=sys.stderr)
23
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT))
24
+
25
+ root = logging.getLogger()
26
+ root.handlers.clear()
27
+ root.addHandler(handler)
28
+ root.setLevel(_coerce_level(level))
29
+
30
+ # Quiet noisy third-party loggers.
31
+ for noisy in ("httpx", "httpcore", "git", "urllib3"):
32
+ logging.getLogger(noisy).setLevel(logging.WARNING)
33
+
34
+ _configured = True
35
+
36
+
37
+ def get_logger(name: str) -> logging.Logger:
38
+ """Return a configured logger, configuring root logging lazily."""
39
+ if not _configured:
40
+ configure_logging()
41
+ return logging.getLogger(name)
42
+
43
+
44
+ def _coerce_level(level: str) -> int:
45
+ if level.isdigit():
46
+ return int(level)
47
+ return logging.getLevelName(level.upper())