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 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
+ ]
@@ -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
+ ]
@@ -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))
@@ -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
+ )