ensemblai 0.1.0__tar.gz
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.
- ensemblai-0.1.0/.gitignore +9 -0
- ensemblai-0.1.0/PKG-INFO +96 -0
- ensemblai-0.1.0/README.md +77 -0
- ensemblai-0.1.0/ensemblai/__init__.py +8 -0
- ensemblai-0.1.0/ensemblai/cli.py +86 -0
- ensemblai-0.1.0/ensemblai/client.py +134 -0
- ensemblai-0.1.0/pyproject.toml +32 -0
- ensemblai-0.1.0/tests/test_client.py +60 -0
ensemblai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ensemblai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client + CLI for EnsemblAI — download analytics, growth, dependencies, and ownership for PyPI and npm.
|
|
5
|
+
Project-URL: Homepage, https://www.ensemblai.com
|
|
6
|
+
Project-URL: Documentation, https://www.ensemblai.com/docs/agents
|
|
7
|
+
Project-URL: API Reference, https://api.ensemblai.com/openapi.json
|
|
8
|
+
Author: EnsemblAI
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: analytics,dependencies,downloads,ecosystem,mcp,npm,package,pypi
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: httpx>=0.24
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# ensemblai
|
|
21
|
+
|
|
22
|
+
Python client and CLI for [EnsemblAI](https://www.ensemblai.com) — software
|
|
23
|
+
ecosystem intelligence for **PyPI and npm**: download analytics, month-over-month
|
|
24
|
+
growth, dependency structure, and corporate ownership across millions of
|
|
25
|
+
packages with multi-year daily history.
|
|
26
|
+
|
|
27
|
+
> **Requires a Pro-plan API key.** Create an account, subscribe, and mint a key
|
|
28
|
+
> at <https://www.ensemblai.com/settings/api-keys>. Prefer an AI agent? Connect
|
|
29
|
+
> Claude Code, Cursor, or Codex to the MCP server instead — see
|
|
30
|
+
> <https://www.ensemblai.com/docs/agents>.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install ensemblai # or: uv pip install ensemblai
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ensemblai import Client
|
|
42
|
+
|
|
43
|
+
ea = Client() # reads ENSEMBLAI_API_KEY from the environment
|
|
44
|
+
|
|
45
|
+
numpy = ea.package("numpy")
|
|
46
|
+
print(numpy["package_name"], numpy.get("latest_downloads"))
|
|
47
|
+
|
|
48
|
+
for row in ea.top(limit=10, ecosystem="npm"):
|
|
49
|
+
print(row)
|
|
50
|
+
|
|
51
|
+
trend = ea.time_series(["requests", "httpx"], granularity="monthly")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Pass the key explicitly if you'd rather not use the environment:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
ea = Client(api_key="ek_live_...", ecosystem="npm")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## CLI
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
export ENSEMBLAI_API_KEY=ek_live_...
|
|
64
|
+
|
|
65
|
+
ensemblai pkg numpy
|
|
66
|
+
ensemblai top --eco npm --limit 10
|
|
67
|
+
ensemblai search "http client"
|
|
68
|
+
ensemblai compare requests httpx aiohttp
|
|
69
|
+
ensemblai companies --limit 20
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
| Method | REST endpoint | Returns |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `package(name)` | `GET /v1/packages/{name}` | one package's full profile |
|
|
77
|
+
| `search(query, limit=)` | `GET /v1/packages` | matching packages |
|
|
78
|
+
| `top(limit=)` | `GET /v1/leaderboards/downloads` | 30-day download leaderboard |
|
|
79
|
+
| `compare(names)` | `POST /v1/packages/batch` | side-by-side metrics |
|
|
80
|
+
| `time_series(packages, granularity=)` | `GET /v1/analytics/time-series` | download history (weekly is Pro+) |
|
|
81
|
+
| `companies(limit=)` | `GET /v1/companies` | corporate owners by footprint |
|
|
82
|
+
| `reference(kind)` | `GET /v1/reference/{kind}` | valid filter values |
|
|
83
|
+
| `get(path, **params)` | any | escape hatch for the full API |
|
|
84
|
+
|
|
85
|
+
The full surface is documented in the [OpenAPI schema](https://api.ensemblai.com/openapi.json).
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
| Variable | Default | Purpose |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `ENSEMBLAI_API_KEY` | — | your Pro API key (required) |
|
|
92
|
+
| `ENSEMBLAI_BASE_URL` | `https://api.ensemblai.com` | override the API host |
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# ensemblai
|
|
2
|
+
|
|
3
|
+
Python client and CLI for [EnsemblAI](https://www.ensemblai.com) — software
|
|
4
|
+
ecosystem intelligence for **PyPI and npm**: download analytics, month-over-month
|
|
5
|
+
growth, dependency structure, and corporate ownership across millions of
|
|
6
|
+
packages with multi-year daily history.
|
|
7
|
+
|
|
8
|
+
> **Requires a Pro-plan API key.** Create an account, subscribe, and mint a key
|
|
9
|
+
> at <https://www.ensemblai.com/settings/api-keys>. Prefer an AI agent? Connect
|
|
10
|
+
> Claude Code, Cursor, or Codex to the MCP server instead — see
|
|
11
|
+
> <https://www.ensemblai.com/docs/agents>.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install ensemblai # or: uv pip install ensemblai
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from ensemblai import Client
|
|
23
|
+
|
|
24
|
+
ea = Client() # reads ENSEMBLAI_API_KEY from the environment
|
|
25
|
+
|
|
26
|
+
numpy = ea.package("numpy")
|
|
27
|
+
print(numpy["package_name"], numpy.get("latest_downloads"))
|
|
28
|
+
|
|
29
|
+
for row in ea.top(limit=10, ecosystem="npm"):
|
|
30
|
+
print(row)
|
|
31
|
+
|
|
32
|
+
trend = ea.time_series(["requests", "httpx"], granularity="monthly")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Pass the key explicitly if you'd rather not use the environment:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
ea = Client(api_key="ek_live_...", ecosystem="npm")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export ENSEMBLAI_API_KEY=ek_live_...
|
|
45
|
+
|
|
46
|
+
ensemblai pkg numpy
|
|
47
|
+
ensemblai top --eco npm --limit 10
|
|
48
|
+
ensemblai search "http client"
|
|
49
|
+
ensemblai compare requests httpx aiohttp
|
|
50
|
+
ensemblai companies --limit 20
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Methods
|
|
54
|
+
|
|
55
|
+
| Method | REST endpoint | Returns |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `package(name)` | `GET /v1/packages/{name}` | one package's full profile |
|
|
58
|
+
| `search(query, limit=)` | `GET /v1/packages` | matching packages |
|
|
59
|
+
| `top(limit=)` | `GET /v1/leaderboards/downloads` | 30-day download leaderboard |
|
|
60
|
+
| `compare(names)` | `POST /v1/packages/batch` | side-by-side metrics |
|
|
61
|
+
| `time_series(packages, granularity=)` | `GET /v1/analytics/time-series` | download history (weekly is Pro+) |
|
|
62
|
+
| `companies(limit=)` | `GET /v1/companies` | corporate owners by footprint |
|
|
63
|
+
| `reference(kind)` | `GET /v1/reference/{kind}` | valid filter values |
|
|
64
|
+
| `get(path, **params)` | any | escape hatch for the full API |
|
|
65
|
+
|
|
66
|
+
The full surface is documented in the [OpenAPI schema](https://api.ensemblai.com/openapi.json).
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Variable | Default | Purpose |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `ENSEMBLAI_API_KEY` | — | your Pro API key (required) |
|
|
73
|
+
| `ENSEMBLAI_BASE_URL` | `https://api.ensemblai.com` | override the API host |
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""EnsemblAI — software ecosystem intelligence for PyPI and npm.
|
|
2
|
+
|
|
3
|
+
Download analytics, growth trends, dependency structure, and corporate
|
|
4
|
+
ownership. Requires a Pro-plan API key: https://www.ensemblai.com/settings/api-keys
|
|
5
|
+
"""
|
|
6
|
+
from .client import Client, EnsemblAIError, __version__
|
|
7
|
+
|
|
8
|
+
__all__ = ["Client", "EnsemblAIError", "__version__"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command-line interface for EnsemblAI.
|
|
2
|
+
|
|
3
|
+
export ENSEMBLAI_API_KEY=ek_live_... # Pro plan
|
|
4
|
+
ensemblai pkg numpy
|
|
5
|
+
ensemblai top --eco npm --limit 10
|
|
6
|
+
ensemblai search "http client"
|
|
7
|
+
ensemblai compare requests httpx aiohttp
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .client import Client, EnsemblAIError, __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _emit(obj: Any) -> None:
|
|
20
|
+
json.dump(obj, sys.stdout, indent=2, default=str)
|
|
21
|
+
sys.stdout.write("\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _client(args: argparse.Namespace) -> Client:
|
|
25
|
+
return Client(ecosystem=args.eco)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(
|
|
30
|
+
prog="ensemblai",
|
|
31
|
+
description="EnsemblAI — PyPI & npm ecosystem intelligence. "
|
|
32
|
+
"Set ENSEMBLAI_API_KEY (Pro plan). Docs: https://www.ensemblai.com/docs/agents",
|
|
33
|
+
)
|
|
34
|
+
p.add_argument("--version", action="version", version=f"ensemblai {__version__}")
|
|
35
|
+
|
|
36
|
+
# Shared options every subcommand inherits, so `ensemblai top --eco npm`
|
|
37
|
+
# works (a top-level --eco would have to precede the subcommand).
|
|
38
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
39
|
+
common.add_argument("--eco", default="pypi", choices=["pypi", "npm"], help="Ecosystem (default: pypi)")
|
|
40
|
+
|
|
41
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
42
|
+
|
|
43
|
+
sp = sub.add_parser("pkg", parents=[common], help="Full profile for one package")
|
|
44
|
+
sp.add_argument("name")
|
|
45
|
+
|
|
46
|
+
sp = sub.add_parser("search", parents=[common], help="Search packages")
|
|
47
|
+
sp.add_argument("query")
|
|
48
|
+
sp.add_argument("--limit", type=int, default=20)
|
|
49
|
+
|
|
50
|
+
sp = sub.add_parser("top", parents=[common], help="Download leaderboard")
|
|
51
|
+
sp.add_argument("--limit", type=int, default=25)
|
|
52
|
+
|
|
53
|
+
sp = sub.add_parser("compare", parents=[common], help="Compare packages side by side")
|
|
54
|
+
sp.add_argument("names", nargs="+")
|
|
55
|
+
|
|
56
|
+
sp = sub.add_parser("companies", parents=[common], help="Corporate owners by footprint")
|
|
57
|
+
sp.add_argument("--limit", type=int, default=25)
|
|
58
|
+
|
|
59
|
+
return p
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
try:
|
|
65
|
+
with _client(args) as ea:
|
|
66
|
+
if args.cmd == "pkg":
|
|
67
|
+
_emit(ea.package(args.name))
|
|
68
|
+
elif args.cmd == "search":
|
|
69
|
+
_emit(ea.search(args.query, limit=args.limit))
|
|
70
|
+
elif args.cmd == "top":
|
|
71
|
+
_emit(ea.top(limit=args.limit))
|
|
72
|
+
elif args.cmd == "compare":
|
|
73
|
+
_emit(ea.compare(args.names))
|
|
74
|
+
elif args.cmd == "companies":
|
|
75
|
+
_emit(ea.companies(limit=args.limit))
|
|
76
|
+
except ValueError as e: # missing key
|
|
77
|
+
print(f"error: {e}", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
except EnsemblAIError as e:
|
|
80
|
+
print(f"error: {e}", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Thin, typed client for the EnsemblAI REST API.
|
|
2
|
+
|
|
3
|
+
The API base defaults to https://api.ensemblai.com and can be overridden with
|
|
4
|
+
ENSEMBLAI_BASE_URL. Authentication uses a Pro-plan API key (``ek_live_…``),
|
|
5
|
+
read from the ``api_key`` argument or the ENSEMBLAI_API_KEY environment
|
|
6
|
+
variable. Get a key at https://www.ensemblai.com/settings/api-keys.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.ensemblai.com"
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnsemblAIError(RuntimeError):
|
|
20
|
+
"""Raised when the API returns a non-2xx response."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status: int, message: str, request_id: Optional[str] = None):
|
|
23
|
+
self.status = status
|
|
24
|
+
self.request_id = request_id
|
|
25
|
+
super().__init__(f"[{status}] {message}" + (f" (request_id={request_id})" if request_id else ""))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Client:
|
|
29
|
+
"""A synchronous EnsemblAI API client.
|
|
30
|
+
|
|
31
|
+
>>> from ensemblai import Client
|
|
32
|
+
>>> ea = Client() # key from ENSEMBLAI_API_KEY
|
|
33
|
+
>>> ea.package("numpy")["package_name"]
|
|
34
|
+
'numpy'
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: Optional[str] = None,
|
|
40
|
+
*,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
ecosystem: str = "pypi",
|
|
43
|
+
timeout: float = 30.0,
|
|
44
|
+
):
|
|
45
|
+
self.api_key = api_key or os.environ.get("ENSEMBLAI_API_KEY", "")
|
|
46
|
+
if not self.api_key:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"No API key. Pass api_key= or set ENSEMBLAI_API_KEY. "
|
|
49
|
+
"Get one (Pro plan) at https://www.ensemblai.com/settings/api-keys"
|
|
50
|
+
)
|
|
51
|
+
self.base_url = (base_url or os.environ.get("ENSEMBLAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
52
|
+
self.ecosystem = ecosystem
|
|
53
|
+
self._http = httpx.Client(
|
|
54
|
+
base_url=self.base_url,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
headers={
|
|
57
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
58
|
+
"User-Agent": f"ensemblai-python/{__version__}",
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# -- low-level --------------------------------------------------------
|
|
63
|
+
def _request(self, method: str, path: str, *, params: Optional[dict] = None, json: Any = None) -> Any:
|
|
64
|
+
params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
65
|
+
params.setdefault("ecosystem", self.ecosystem)
|
|
66
|
+
resp = self._http.request(method, path, params=params, json=json)
|
|
67
|
+
if resp.status_code >= 400:
|
|
68
|
+
body: dict = {}
|
|
69
|
+
try:
|
|
70
|
+
body = resp.json()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
err = body.get("error", {}) if isinstance(body, dict) else {}
|
|
74
|
+
meta = body.get("meta", {}) if isinstance(body, dict) else {}
|
|
75
|
+
raise EnsemblAIError(
|
|
76
|
+
resp.status_code,
|
|
77
|
+
(err.get("message") if isinstance(err, dict) else None) or resp.text[:200] or "request failed",
|
|
78
|
+
meta.get("request_id") if isinstance(meta, dict) else None,
|
|
79
|
+
)
|
|
80
|
+
payload = resp.json()
|
|
81
|
+
# The API wraps successes as {"data": ..., "meta": ...}; return data.
|
|
82
|
+
return payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
83
|
+
|
|
84
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
85
|
+
"""Escape hatch: GET any endpoint from the OpenAPI schema."""
|
|
86
|
+
return self._request("GET", path, params=params)
|
|
87
|
+
|
|
88
|
+
# -- typed convenience methods ---------------------------------------
|
|
89
|
+
def package(self, name: str, *, ecosystem: Optional[str] = None) -> dict:
|
|
90
|
+
"""Full profile for one package by its exact (lowercase) name."""
|
|
91
|
+
return self._request("GET", f"/v1/packages/{name}", params={"ecosystem": ecosystem})
|
|
92
|
+
|
|
93
|
+
def search(self, query: str, *, limit: int = 20, ecosystem: Optional[str] = None) -> Any:
|
|
94
|
+
"""Search packages by name, summary, tags, or description."""
|
|
95
|
+
return self._request("GET", "/v1/packages", params={"q": query, "limit": limit, "ecosystem": ecosystem})
|
|
96
|
+
|
|
97
|
+
def top(self, *, limit: int = 25, ecosystem: Optional[str] = None) -> Any:
|
|
98
|
+
"""The download leaderboard — packages ranked by 30-day downloads."""
|
|
99
|
+
return self._request("GET", "/v1/leaderboards/downloads", params={"limit": limit, "ecosystem": ecosystem})
|
|
100
|
+
|
|
101
|
+
def compare(self, names: Sequence[str], *, ecosystem: Optional[str] = None) -> Any:
|
|
102
|
+
"""Compare packages side by side (30-day downloads, stars, quality)."""
|
|
103
|
+
return self._request("POST", "/v1/packages/batch", params={"ecosystem": ecosystem},
|
|
104
|
+
json={"packages": list(names)})
|
|
105
|
+
|
|
106
|
+
def time_series(self, packages: Sequence[str] | str, *, granularity: str = "monthly",
|
|
107
|
+
start_date: Optional[str] = None, end_date: Optional[str] = None,
|
|
108
|
+
ecosystem: Optional[str] = None) -> Any:
|
|
109
|
+
"""Download history for one or more packages over a window.
|
|
110
|
+
|
|
111
|
+
Weekly granularity requires Pro+ (monthly always available).
|
|
112
|
+
"""
|
|
113
|
+
pkgs = packages if isinstance(packages, str) else ",".join(packages)
|
|
114
|
+
return self._request("GET", "/v1/analytics/time-series", params={
|
|
115
|
+
"packages": pkgs, "granularity": granularity,
|
|
116
|
+
"start_date": start_date, "end_date": end_date, "ecosystem": ecosystem,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
def companies(self, *, limit: int = 25, ecosystem: Optional[str] = None) -> Any:
|
|
120
|
+
"""Corporate owners ranked by their open-source footprint."""
|
|
121
|
+
return self._request("GET", "/v1/companies", params={"limit": limit, "ecosystem": ecosystem})
|
|
122
|
+
|
|
123
|
+
def reference(self, kind: str) -> Any:
|
|
124
|
+
"""Valid filter values for a dimension (domains, categories, licenses, …)."""
|
|
125
|
+
return self._request("GET", f"/v1/reference/{kind}")
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
self._http.close()
|
|
129
|
+
|
|
130
|
+
def __enter__(self) -> "Client":
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *exc: Any) -> None:
|
|
134
|
+
self.close()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ensemblai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client + CLI for EnsemblAI — download analytics, growth, dependencies, and ownership for PyPI and npm."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EnsemblAI" }]
|
|
13
|
+
keywords = ["pypi", "npm", "analytics", "downloads", "dependencies", "package", "ecosystem", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["httpx>=0.24"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://www.ensemblai.com"
|
|
25
|
+
Documentation = "https://www.ensemblai.com/docs/agents"
|
|
26
|
+
"API Reference" = "https://api.ensemblai.com/openapi.json"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
ensemblai = "ensemblai.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["ensemblai"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Offline unit tests — no network, no key required (mock transport)."""
|
|
2
|
+
import httpx
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ensemblai import Client, EnsemblAIError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _client(handler):
|
|
9
|
+
ea = Client(api_key="ek_live_test", base_url="https://api.example.invalid")
|
|
10
|
+
ea._http = httpx.Client(
|
|
11
|
+
base_url="https://api.example.invalid",
|
|
12
|
+
transport=httpx.MockTransport(handler),
|
|
13
|
+
headers={"Authorization": "Bearer ek_live_test"},
|
|
14
|
+
)
|
|
15
|
+
return ea
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_requires_key(monkeypatch):
|
|
19
|
+
monkeypatch.delenv("ENSEMBLAI_API_KEY", raising=False)
|
|
20
|
+
with pytest.raises(ValueError):
|
|
21
|
+
Client()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_unwraps_data_envelope():
|
|
25
|
+
def handler(req):
|
|
26
|
+
assert req.url.path == "/v1/packages/numpy"
|
|
27
|
+
assert req.url.params.get("ecosystem") == "pypi"
|
|
28
|
+
return httpx.Response(200, json={"data": {"package_name": "numpy"}, "meta": {"request_id": "req_1"}})
|
|
29
|
+
|
|
30
|
+
assert _client(handler).package("numpy") == {"package_name": "numpy"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_ecosystem_override_flows_through():
|
|
34
|
+
def handler(req):
|
|
35
|
+
assert req.url.params.get("ecosystem") == "npm"
|
|
36
|
+
return httpx.Response(200, json={"data": []})
|
|
37
|
+
|
|
38
|
+
_client(handler).top(ecosystem="npm")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_compare_posts_body():
|
|
42
|
+
def handler(req):
|
|
43
|
+
assert req.method == "POST"
|
|
44
|
+
assert req.url.path == "/v1/packages/batch"
|
|
45
|
+
import json
|
|
46
|
+
assert json.loads(req.content)["packages"] == ["requests", "httpx"]
|
|
47
|
+
return httpx.Response(200, json={"data": [{"package_name": "requests"}]})
|
|
48
|
+
|
|
49
|
+
_client(handler).compare(["requests", "httpx"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_error_surfaces_message_and_request_id():
|
|
53
|
+
def handler(req):
|
|
54
|
+
return httpx.Response(403, json={"error": {"message": "Pro plan required"}, "meta": {"request_id": "req_x"}})
|
|
55
|
+
|
|
56
|
+
with pytest.raises(EnsemblAIError) as e:
|
|
57
|
+
_client(handler).package("numpy")
|
|
58
|
+
assert e.value.status == 403
|
|
59
|
+
assert "Pro plan required" in str(e.value)
|
|
60
|
+
assert e.value.request_id == "req_x"
|