apiforge-sdk 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.
- apiforge/__init__.py +40 -0
- apiforge/cli/__init__.py +11 -0
- apiforge/cli/main.py +197 -0
- apiforge/core/__init__.py +29 -0
- apiforge/core/client.py +255 -0
- apiforge/core/config.py +62 -0
- apiforge/core/credentials.py +259 -0
- apiforge/core/discovery.py +105 -0
- apiforge/core/exceptions.py +58 -0
- apiforge/core/metadata.py +79 -0
- apiforge/generators/__init__.py +78 -0
- apiforge/mcp/__init__.py +12 -0
- apiforge/mcp/adapter.py +92 -0
- apiforge/mcp/generator.py +90 -0
- apiforge/plugins/__init__.py +97 -0
- apiforge/plugins/base.py +12 -0
- apiforge/plugins/discord/__init__.py +88 -0
- apiforge/plugins/github/__init__.py +122 -0
- apiforge/plugins/notion/__init__.py +104 -0
- apiforge_sdk-0.1.0.dist-info/METADATA +264 -0
- apiforge_sdk-0.1.0.dist-info/RECORD +24 -0
- apiforge_sdk-0.1.0.dist-info/WHEEL +4 -0
- apiforge_sdk-0.1.0.dist-info/entry_points.txt +7 -0
- apiforge_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
apiforge/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""APIForge — Unified interface for multiple APIs.
|
|
2
|
+
|
|
3
|
+
A plugin-based framework that provides a single, consistent Python API for
|
|
4
|
+
interacting with many external services (GitHub, Discord, Notion, and many
|
|
5
|
+
more). Authentication, HTTP transport, and plugin discovery are handled
|
|
6
|
+
centrally so individual plugins can focus on the service-specific surface.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from apiforge.core.client import APIForge
|
|
10
|
+
from apiforge.core.config import APIForgeConfig
|
|
11
|
+
from apiforge.core.credentials import CredentialManager
|
|
12
|
+
from apiforge.core.discovery import PluginRegistry
|
|
13
|
+
from apiforge.core.exceptions import (
|
|
14
|
+
APIForgeError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
PluginError,
|
|
17
|
+
PluginNotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
)
|
|
20
|
+
from apiforge.core.metadata import AuthType, OperationMetadata, PluginMetadata
|
|
21
|
+
from apiforge.plugins.base import BasePlugin
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"APIForge",
|
|
27
|
+
"APIForgeConfig",
|
|
28
|
+
"APIForgeError",
|
|
29
|
+
"AuthType",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"BasePlugin",
|
|
32
|
+
"CredentialManager",
|
|
33
|
+
"OperationMetadata",
|
|
34
|
+
"PluginError",
|
|
35
|
+
"PluginMetadata",
|
|
36
|
+
"PluginNotFoundError",
|
|
37
|
+
"PluginRegistry",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
apiforge/cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
The CLI is intentionally thin: it shells out to the library rather
|
|
4
|
+
than re-implementing logic. Commands are organized as subcommands so
|
|
5
|
+
we can add ``generate``, ``install``, ``update`` in later phases
|
|
6
|
+
without breaking compatibility.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from apiforge.cli.main import cli
|
|
10
|
+
|
|
11
|
+
__all__ = ["cli"]
|
apiforge/cli/main.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The ``apiforge`` command.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
* ``apiforge list`` — list registered plugins with one-line summaries.
|
|
6
|
+
* ``apiforge plugins`` — same as ``list`` (alias).
|
|
7
|
+
* ``apiforge info <name>`` — show full metadata for a plugin.
|
|
8
|
+
* ``apiforge generate`` — generate a plugin from an OpenAPI spec. (TODO[Phase 2])
|
|
9
|
+
* ``apiforge install`` — install a plugin package. (TODO[Phase 5])
|
|
10
|
+
* ``apiforge update`` — update a plugin package. (TODO[Phase 5])
|
|
11
|
+
|
|
12
|
+
The future subcommands are wired with ``NotImplementedError`` calls
|
|
13
|
+
so users get a clear error rather than a silent pass. We also pre-
|
|
14
|
+
register the commands in the parser so ``apiforge generate --help``
|
|
15
|
+
works today.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from apiforge import APIForge
|
|
26
|
+
from apiforge.core.exceptions import APIForgeError, PluginNotFoundError
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------- shared
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_client() -> APIForge:
|
|
32
|
+
"""Factory used by every subcommand."""
|
|
33
|
+
return APIForge()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------- list
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.command(name="list")
|
|
40
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
41
|
+
def list_cmd(as_json: bool) -> None:
|
|
42
|
+
"""List all registered plugins."""
|
|
43
|
+
client = _make_client()
|
|
44
|
+
plugins = []
|
|
45
|
+
for name in client.list_plugins():
|
|
46
|
+
meta = client.get_plugin(name).metadata
|
|
47
|
+
plugins.append(
|
|
48
|
+
{
|
|
49
|
+
"name": meta.name,
|
|
50
|
+
"version": meta.version,
|
|
51
|
+
"description": meta.description,
|
|
52
|
+
"auth_type": meta.auth_type.value,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
if as_json:
|
|
56
|
+
click.echo(json.dumps(plugins, indent=2))
|
|
57
|
+
return
|
|
58
|
+
if not plugins:
|
|
59
|
+
click.echo("No plugins installed.")
|
|
60
|
+
click.echo("Install one with: pip install <apiforge-plugin-name>")
|
|
61
|
+
return
|
|
62
|
+
# Manual width calculation keeps the table readable without pulling
|
|
63
|
+
# in a third-party table library for one screen of output.
|
|
64
|
+
name_w = max(len(p["name"]) for p in plugins)
|
|
65
|
+
ver_w = max(len(p["version"]) for p in plugins)
|
|
66
|
+
click.echo(f"{'NAME'.ljust(name_w)} {'VERSION'.ljust(ver_w)} AUTH DESCRIPTION")
|
|
67
|
+
click.echo("-" * (name_w + ver_w + 60))
|
|
68
|
+
for p in plugins:
|
|
69
|
+
click.echo(
|
|
70
|
+
f"{p['name'].ljust(name_w)} {p['version'].ljust(ver_w)} "
|
|
71
|
+
f"{p['auth_type'].ljust(5)} {p['description']}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# `apiforge plugins` is an alias for `apiforge list`; we re-use the
|
|
76
|
+
# same function under a different name.
|
|
77
|
+
plugins_cmd = click.command(name="plugins")(list_cmd.callback) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------- info
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.command(name="info")
|
|
84
|
+
@click.argument("name")
|
|
85
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
86
|
+
def info_cmd(name: str, as_json: bool) -> None:
|
|
87
|
+
"""Show detailed information about a single plugin."""
|
|
88
|
+
client = _make_client()
|
|
89
|
+
try:
|
|
90
|
+
plugin = client.get_plugin(name)
|
|
91
|
+
except PluginNotFoundError as exc:
|
|
92
|
+
raise click.ClickException(str(exc)) from exc
|
|
93
|
+
meta = plugin.metadata
|
|
94
|
+
payload: dict[str, Any] = {
|
|
95
|
+
"name": meta.name,
|
|
96
|
+
"version": meta.version,
|
|
97
|
+
"description": meta.description,
|
|
98
|
+
"auth_type": meta.auth_type.value,
|
|
99
|
+
"base_url": meta.base_url,
|
|
100
|
+
"openapi_url": meta.openapi_url,
|
|
101
|
+
"operations": [
|
|
102
|
+
{
|
|
103
|
+
"name": op.name,
|
|
104
|
+
"description": op.description,
|
|
105
|
+
"async": op.async_,
|
|
106
|
+
"parameters": op.parameters,
|
|
107
|
+
}
|
|
108
|
+
for op in meta.operations
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
if as_json:
|
|
112
|
+
click.echo(json.dumps(payload, indent=2))
|
|
113
|
+
return
|
|
114
|
+
click.echo(f"{payload['name']} v{payload['version']}")
|
|
115
|
+
click.echo(f" {payload['description']}")
|
|
116
|
+
click.echo(f" Auth: {payload['auth_type']}")
|
|
117
|
+
if payload["base_url"]:
|
|
118
|
+
click.echo(f" Base URL: {payload['base_url']}")
|
|
119
|
+
if payload["openapi_url"]:
|
|
120
|
+
click.echo(f" OpenAPI: {payload['openapi_url']}")
|
|
121
|
+
if payload["operations"]:
|
|
122
|
+
click.echo(" Operations:")
|
|
123
|
+
for op in payload["operations"]:
|
|
124
|
+
params = ", ".join(op["parameters"]) or "<no params>"
|
|
125
|
+
desc = f" — {op['description']}" if op["description"] else ""
|
|
126
|
+
click.echo(f" - {op['name']}({params}){desc}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------- future commands
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@click.command(name="generate")
|
|
133
|
+
@click.argument("spec_source")
|
|
134
|
+
@click.option("--name", "plugin_name", default=None, help="Plugin name override.")
|
|
135
|
+
def generate_cmd(spec_source: str, plugin_name: str | None) -> None:
|
|
136
|
+
"""Generate a plugin from an OpenAPI spec.
|
|
137
|
+
|
|
138
|
+
TODO[Phase 2]: implement using the OpenAPIGenerator. Today this
|
|
139
|
+
raises an error so the parser wiring can be tested.
|
|
140
|
+
"""
|
|
141
|
+
raise click.UsageError(f"Generation from '{spec_source}' is not implemented yet (Phase 2).")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@click.command(name="install")
|
|
145
|
+
@click.argument("package")
|
|
146
|
+
def install_cmd(package: str) -> None:
|
|
147
|
+
"""Install a plugin package from PyPI or a local path.
|
|
148
|
+
|
|
149
|
+
TODO[Phase 5]: implement.
|
|
150
|
+
"""
|
|
151
|
+
raise click.UsageError(f"Install of '{package}' is not implemented yet (Phase 5).")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@click.command(name="update")
|
|
155
|
+
@click.argument("package", required=False)
|
|
156
|
+
def update_cmd(package: str | None) -> None:
|
|
157
|
+
"""Update installed plugins.
|
|
158
|
+
|
|
159
|
+
TODO[Phase 5]: implement.
|
|
160
|
+
"""
|
|
161
|
+
target = package or "all plugins"
|
|
162
|
+
raise click.UsageError(f"Update of '{target}' is not implemented yet (Phase 5).")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------- root
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
169
|
+
@click.version_option(package_name="apiforge", prog_name="apiforge")
|
|
170
|
+
def cli() -> None:
|
|
171
|
+
"""APIForge — unified interface for many APIs."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Register subcommands. Order matters for ``--help`` output: list
|
|
175
|
+
# functionality first, future work last.
|
|
176
|
+
cli.add_command(list_cmd)
|
|
177
|
+
cli.add_command(plugins_cmd)
|
|
178
|
+
cli.add_command(info_cmd)
|
|
179
|
+
cli.add_command(generate_cmd)
|
|
180
|
+
cli.add_command(install_cmd)
|
|
181
|
+
cli.add_command(update_cmd)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------- entrypoint
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main() -> None:
|
|
188
|
+
"""Console-script entry point. Catches APIForge errors and exits 1."""
|
|
189
|
+
try:
|
|
190
|
+
cli(standalone_mode=True)
|
|
191
|
+
except APIForgeError as exc:
|
|
192
|
+
click.echo(f"apiforge: {exc}", err=True)
|
|
193
|
+
raise SystemExit(1) from exc
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__": # pragma: no cover
|
|
197
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Core abstractions: configuration, credentials, discovery, errors."""
|
|
2
|
+
|
|
3
|
+
from apiforge.core.client import APIForge
|
|
4
|
+
from apiforge.core.config import APIForgeConfig
|
|
5
|
+
from apiforge.core.credentials import CredentialManager
|
|
6
|
+
from apiforge.core.discovery import PluginRegistry
|
|
7
|
+
from apiforge.core.exceptions import (
|
|
8
|
+
APIForgeError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
PluginError,
|
|
11
|
+
PluginNotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
)
|
|
14
|
+
from apiforge.core.metadata import AuthType, PluginMetadata
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"APIForge",
|
|
18
|
+
"APIForgeConfig",
|
|
19
|
+
"APIForgeConfig",
|
|
20
|
+
"APIForgeError",
|
|
21
|
+
"AuthType",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"CredentialManager",
|
|
24
|
+
"PluginError",
|
|
25
|
+
"PluginMetadata",
|
|
26
|
+
"PluginNotFoundError",
|
|
27
|
+
"PluginRegistry",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
]
|
apiforge/core/client.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""The :class:`APIForge` client — the user-facing entry point.
|
|
2
|
+
|
|
3
|
+
The client owns three long-lived resources and shares them with every
|
|
4
|
+
plugin it instantiates:
|
|
5
|
+
|
|
6
|
+
1. A :class:`~httpx.AsyncClient` for HTTP transport.
|
|
7
|
+
2. A :class:`~apiforge.core.credentials.CredentialManager` for secrets.
|
|
8
|
+
3. A :class:`~apiforge.core.config.APIForgeConfig` for behavior knobs.
|
|
9
|
+
|
|
10
|
+
Plugins are accessed by attribute (``client.github``). On first access
|
|
11
|
+
we instantiate the plugin, run its ``setup()`` hook, and cache the
|
|
12
|
+
instance. This is the lazy-initialization pattern — it's faster than
|
|
13
|
+
the alternative and lets plugins do per-instance work (token refresh,
|
|
14
|
+
connection pools) at exactly the right moment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from apiforge.core.config import APIForgeConfig
|
|
25
|
+
from apiforge.core.credentials import CredentialManager
|
|
26
|
+
from apiforge.core.discovery import PluginRegistry
|
|
27
|
+
from apiforge.core.exceptions import PluginError, PluginNotFoundError
|
|
28
|
+
from apiforge.plugins.base import BasePlugin
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIForge:
|
|
32
|
+
"""The unified API client.
|
|
33
|
+
|
|
34
|
+
>>> client = APIForge()
|
|
35
|
+
>>> client.configure(github_token="ghp_xxx")
|
|
36
|
+
>>> user = await client.github.get_user("octocat")
|
|
37
|
+
|
|
38
|
+
Use the client as an async context manager to ensure the underlying
|
|
39
|
+
HTTP connection pool is closed cleanly::
|
|
40
|
+
|
|
41
|
+
async with APIForge() as client:
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
The client is reusable: calling :meth:`close` (or exiting the
|
|
45
|
+
context manager) and then making further calls will reopen the
|
|
46
|
+
transport.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
config: APIForgeConfig | None = None,
|
|
53
|
+
credentials: CredentialManager | None = None,
|
|
54
|
+
registry: PluginRegistry | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
# Configuration ----------------------------------------------------------
|
|
57
|
+
# Each subsystem has a default; users can override any of them.
|
|
58
|
+
# We keep dependencies explicit so a test can construct an
|
|
59
|
+
# APIForge with a fake registry and a fake credential manager.
|
|
60
|
+
self.config = config or APIForgeConfig()
|
|
61
|
+
self.credentials = credentials or CredentialManager()
|
|
62
|
+
self.registry = registry or PluginRegistry()
|
|
63
|
+
|
|
64
|
+
# State -------------------------------------------------------------------
|
|
65
|
+
# ``_http`` is created lazily because constructing an httpx
|
|
66
|
+
# client at import time would require an async context, which
|
|
67
|
+
# conflicts with synchronous user code paths.
|
|
68
|
+
self._http: httpx.AsyncClient | None = None
|
|
69
|
+
self._plugins: dict[str, BasePlugin] = {}
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------ configuration
|
|
72
|
+
|
|
73
|
+
def configure(self, **credentials: str) -> None:
|
|
74
|
+
"""Set explicit credentials for one or more plugins.
|
|
75
|
+
|
|
76
|
+
Unknown keyword arguments are still recorded under the literal
|
|
77
|
+
name, so users can pass ``client.configure(my_custom_token="...")``
|
|
78
|
+
even if no plugin with that exact name is registered yet —
|
|
79
|
+
this lets a script populate credentials for a plugin installed
|
|
80
|
+
later in the same process.
|
|
81
|
+
|
|
82
|
+
Examples
|
|
83
|
+
--------
|
|
84
|
+
>>> client.configure(
|
|
85
|
+
... github_token="ghp_xxx",
|
|
86
|
+
... discord_token="...",
|
|
87
|
+
... notion_token="...",
|
|
88
|
+
... )
|
|
89
|
+
"""
|
|
90
|
+
for key, value in credentials.items():
|
|
91
|
+
if not isinstance(value, str):
|
|
92
|
+
msg = f"Credential for '{key}' must be a string, got {type(value).__name__}"
|
|
93
|
+
raise PluginError(msg)
|
|
94
|
+
# Strip ``_token`` suffix if present so users can pass
|
|
95
|
+
# either ``github_token`` or ``github``.
|
|
96
|
+
name = key.removesuffix("_token")
|
|
97
|
+
self.credentials.set(name, value)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------ transport
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def http(self) -> httpx.AsyncClient:
|
|
103
|
+
"""The shared :class:`httpx.AsyncClient`.
|
|
104
|
+
|
|
105
|
+
Lazily constructed with the configured timeout, SSL, and
|
|
106
|
+
user-agent. Plugins should use this rather than creating their
|
|
107
|
+
own clients — sharing the pool avoids socket exhaustion.
|
|
108
|
+
"""
|
|
109
|
+
if self._http is None:
|
|
110
|
+
self._http = httpx.AsyncClient(
|
|
111
|
+
timeout=httpx.Timeout(self.config.timeout),
|
|
112
|
+
verify=self.config.verify_ssl,
|
|
113
|
+
headers={"User-Agent": self.config.user_agent},
|
|
114
|
+
http2=False, # opt-in via explicit config in the future
|
|
115
|
+
)
|
|
116
|
+
return self._http
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
"""Close the HTTP client and call ``teardown`` on every plugin.
|
|
120
|
+
|
|
121
|
+
Safe to call multiple times.
|
|
122
|
+
"""
|
|
123
|
+
for name, plugin in list(self._plugins.items()):
|
|
124
|
+
try:
|
|
125
|
+
await plugin.teardown()
|
|
126
|
+
except Exception: # noqa: S110
|
|
127
|
+
# A misbehaving teardown should not prevent others
|
|
128
|
+
# from running, and should not prevent the HTTP
|
|
129
|
+
# client from closing.
|
|
130
|
+
pass
|
|
131
|
+
finally:
|
|
132
|
+
self._plugins.pop(name, None)
|
|
133
|
+
if self._http is not None:
|
|
134
|
+
await self._http.aclose()
|
|
135
|
+
self._http = None
|
|
136
|
+
|
|
137
|
+
async def __aenter__(self) -> APIForge:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
141
|
+
await self.close()
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------ plugin access
|
|
144
|
+
|
|
145
|
+
def get_plugin(self, name: str) -> BasePlugin:
|
|
146
|
+
"""Return the instantiated plugin for ``name``.
|
|
147
|
+
|
|
148
|
+
First access instantiates the plugin and runs its ``setup()``
|
|
149
|
+
hook; subsequent accesses return the cached instance.
|
|
150
|
+
|
|
151
|
+
Raises :class:`PluginNotFoundError` if the name is unknown.
|
|
152
|
+
"""
|
|
153
|
+
if name in self._plugins:
|
|
154
|
+
return self._plugins[name]
|
|
155
|
+
cls = self.registry.get(name) # raises PluginNotFoundError
|
|
156
|
+
try:
|
|
157
|
+
instance = cls(self)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
msg = f"Failed to instantiate plugin '{name}': {exc}"
|
|
160
|
+
raise PluginError(msg, plugin=name) from exc
|
|
161
|
+
self._plugins[name] = instance
|
|
162
|
+
# We deliberately do NOT call setup() synchronously here.
|
|
163
|
+
# Plugins that need a setup step are usually async (token
|
|
164
|
+
# refresh, keyring validation). The SyncProxy takes care of
|
|
165
|
+
# awaiting it lazily on first use.
|
|
166
|
+
return instance
|
|
167
|
+
|
|
168
|
+
def has_plugin(self, name: str) -> bool:
|
|
169
|
+
"""Return ``True`` if a plugin named ``name`` is registered."""
|
|
170
|
+
return name in self.registry
|
|
171
|
+
|
|
172
|
+
def list_plugins(self) -> list[str]:
|
|
173
|
+
"""Return the names of all registered plugins, sorted."""
|
|
174
|
+
return self.registry.names()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ attribute proxy
|
|
177
|
+
|
|
178
|
+
def __getattr__(self, name: str) -> Any:
|
|
179
|
+
# ``__getattr__`` is only called when normal lookup fails, so
|
|
180
|
+
# methods like ``close`` and properties like ``http`` still
|
|
181
|
+
# resolve normally. Plugin names are deliberately lower-case
|
|
182
|
+
# to match the documented example (``client.github``).
|
|
183
|
+
if name.startswith("_"):
|
|
184
|
+
raise AttributeError(name)
|
|
185
|
+
try:
|
|
186
|
+
return SyncProxy(self.get_plugin(name))
|
|
187
|
+
except PluginNotFoundError:
|
|
188
|
+
raise AttributeError(
|
|
189
|
+
f"{type(self).__name__!s} has no attribute {name!r}. "
|
|
190
|
+
f"Did you mean one of: {', '.join(self.list_plugins()) or '<none>'}?"
|
|
191
|
+
) from None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SyncProxy:
|
|
195
|
+
"""Wraps an async plugin so sync callers can use it transparently.
|
|
196
|
+
|
|
197
|
+
Every attribute access on the proxy returns a coroutine runner
|
|
198
|
+
for the corresponding async method on the underlying plugin.
|
|
199
|
+
This means::
|
|
200
|
+
|
|
201
|
+
client.github.get_user("octocat")
|
|
202
|
+
|
|
203
|
+
is exactly equivalent to::
|
|
204
|
+
|
|
205
|
+
asyncio.run(client.github.get_user("octocat"))
|
|
206
|
+
|
|
207
|
+
If the underlying plugin mixes sync and async methods, sync ones
|
|
208
|
+
are passed through directly. We detect sync methods by checking
|
|
209
|
+
the class's ``__dict__`` rather than inspecting the coroutine
|
|
210
|
+
flag, because some plugins may legitimately expose coroutine-
|
|
211
|
+
returning callables that aren't ``async def`` (e.g. methods
|
|
212
|
+
built dynamically).
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
__slots__ = ("_plugin",)
|
|
216
|
+
|
|
217
|
+
def __init__(self, plugin: BasePlugin) -> None:
|
|
218
|
+
self._plugin = plugin
|
|
219
|
+
|
|
220
|
+
def __getattr__(self, name: str) -> Any:
|
|
221
|
+
attr = getattr(self._plugin, name)
|
|
222
|
+
# Non-callable attributes: pass through (e.g. ``plugin.metadata``).
|
|
223
|
+
if not callable(attr):
|
|
224
|
+
return attr
|
|
225
|
+
# Sync methods are passed through untouched. This means a plugin
|
|
226
|
+
# that mixes sync and async methods just works — the proxy only
|
|
227
|
+
# wraps the coroutine-returning ones.
|
|
228
|
+
if not inspect.iscoroutinefunction(attr):
|
|
229
|
+
return attr
|
|
230
|
+
return _SyncMethod(self._plugin, name)
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
return f"SyncProxy({self._plugin!r})"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _SyncMethod:
|
|
237
|
+
"""A bound, sync-callable wrapper around an async plugin method.
|
|
238
|
+
|
|
239
|
+
Holding a reference to the plugin (not the bound method) means
|
|
240
|
+
that if the plugin instance is re-created we get a fresh method
|
|
241
|
+
— small but important for tests that swap out the registry.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
__slots__ = ("_name", "_plugin")
|
|
245
|
+
|
|
246
|
+
def __init__(self, plugin: BasePlugin, name: str) -> None:
|
|
247
|
+
self._plugin = plugin
|
|
248
|
+
self._name = name
|
|
249
|
+
|
|
250
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
251
|
+
# Importing here avoids a top-level asyncio dependency.
|
|
252
|
+
import asyncio
|
|
253
|
+
|
|
254
|
+
method = getattr(self._plugin, self._name)
|
|
255
|
+
return asyncio.run(method(*args, **kwargs))
|
apiforge/core/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Runtime configuration for APIForge.
|
|
2
|
+
|
|
3
|
+
We use ``pydantic-settings`` so that configuration can be supplied via:
|
|
4
|
+
|
|
5
|
+
1. ``APIForge.configure(...)`` keyword arguments (highest priority)
|
|
6
|
+
2. Environment variables prefixed with ``APIFORGE_``
|
|
7
|
+
3. A ``.env`` file in the working directory
|
|
8
|
+
|
|
9
|
+
This layered approach lets scripts and libraries configure explicitly,
|
|
10
|
+
while deployed processes (CI, containers) work via environment without
|
|
11
|
+
any code changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIForgeConfig(BaseSettings):
|
|
23
|
+
"""Global configuration shared by the client and all plugins.
|
|
24
|
+
|
|
25
|
+
Per-plugin credentials live on :class:`~apiforge.core.credentials.CredentialManager`,
|
|
26
|
+
not here — this object holds cross-cutting knobs (timeouts, retries,
|
|
27
|
+
log level) that affect every plugin uniformly.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = SettingsConfigDict(
|
|
31
|
+
env_prefix="APIFORGE_",
|
|
32
|
+
env_file=".env",
|
|
33
|
+
env_file_encoding="utf-8",
|
|
34
|
+
extra="ignore",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
timeout: float = Field(
|
|
38
|
+
default=30.0,
|
|
39
|
+
description="Default HTTP timeout in seconds for plugin requests.",
|
|
40
|
+
gt=0,
|
|
41
|
+
)
|
|
42
|
+
max_retries: int = Field(
|
|
43
|
+
default=3,
|
|
44
|
+
description="Default number of retries for transient failures.",
|
|
45
|
+
ge=0,
|
|
46
|
+
)
|
|
47
|
+
verify_ssl: bool = Field(
|
|
48
|
+
default=True,
|
|
49
|
+
description="Whether to verify upstream TLS certificates.",
|
|
50
|
+
)
|
|
51
|
+
log_level: str = Field(
|
|
52
|
+
default="INFO",
|
|
53
|
+
description="Logging level (DEBUG, INFO, WARNING, ERROR).",
|
|
54
|
+
)
|
|
55
|
+
user_agent: str = Field(
|
|
56
|
+
default="apiforge/0.1.0",
|
|
57
|
+
description="User-Agent header sent on every request.",
|
|
58
|
+
)
|
|
59
|
+
extra: dict[str, Any] = Field(
|
|
60
|
+
default_factory=dict,
|
|
61
|
+
description="Free-form key/value bag for plugin-specific settings.",
|
|
62
|
+
)
|