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.
@@ -0,0 +1,9 @@
1
+ # build + dependency artifacts for the SDK packages
2
+ python/.venv/
3
+ python/dist/
4
+ python/build/
5
+ python/*.egg-info/
6
+ python/**/__pycache__/
7
+ node/node_modules/
8
+ node/dist/
9
+ node/package-lock.json
@@ -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"