metagraphed 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.
- metagraphed-0.1.0/.gitignore +7 -0
- metagraphed-0.1.0/PKG-INFO +73 -0
- metagraphed-0.1.0/PUBLISHING.md +39 -0
- metagraphed-0.1.0/README.md +57 -0
- metagraphed-0.1.0/pyproject.toml +26 -0
- metagraphed-0.1.0/src/metagraphed/__init__.py +17 -0
- metagraphed-0.1.0/src/metagraphed/client.py +105 -0
- metagraphed-0.1.0/src/metagraphed/py.typed +0 -0
- metagraphed-0.1.0/tests/test_client.py +89 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metagraphed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin Python client for metagraphed — the operational + integration registry for Bittensor subnets (api.metagraph.sh).
|
|
5
|
+
Project-URL: Homepage, https://metagraph.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/JSONbored/metagraphed
|
|
7
|
+
Author: JSONbored
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: api-client,bittensor,metagraph,registry,subnet
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# metagraphed (Python)
|
|
18
|
+
|
|
19
|
+
Thin, dependency-free Python client for **metagraphed** — the operational +
|
|
20
|
+
integration registry for Bittensor subnets at `https://api.metagraph.sh`.
|
|
21
|
+
|
|
22
|
+
It mirrors the [npm client](https://www.npmjs.com/package/@jsonbored/metagraphed):
|
|
23
|
+
one generic GET helper over the uniform, read-only API surface, returning the
|
|
24
|
+
parsed `{ ok, schema_version, data, meta }` envelope. Stdlib only — no transitive
|
|
25
|
+
dependencies.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install metagraphed
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from metagraphed import MetagraphedClient, metagraphed_fetch
|
|
37
|
+
|
|
38
|
+
client = MetagraphedClient() # base_url defaults to https://api.metagraph.sh
|
|
39
|
+
|
|
40
|
+
# List subnets (query params; None values are dropped)
|
|
41
|
+
subnets = client.fetch(
|
|
42
|
+
"/api/v1/subnets",
|
|
43
|
+
query={"limit": 10, "sort": "completeness_score", "order": "desc"},
|
|
44
|
+
)
|
|
45
|
+
print(subnets["data"][0]["name"])
|
|
46
|
+
|
|
47
|
+
# One subnet by netuid (path params)
|
|
48
|
+
detail = client.fetch("/api/v1/subnets/{netuid}", path_params={"netuid": 7})
|
|
49
|
+
|
|
50
|
+
# Which subnets are buildable? (integration readiness lives in the agent catalog)
|
|
51
|
+
catalog = client.fetch("/api/v1/agent-catalog")
|
|
52
|
+
|
|
53
|
+
# Point at the frontend origin instead, ad hoc:
|
|
54
|
+
metagraphed_fetch("/api/v1/health", base_url="https://metagraph.sh")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every response is the standard envelope:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
{"ok": True, "schema_version": 1, "data": ..., "meta": {...}}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
On a network failure or non-2xx response, a `MetagraphedError` is raised (with
|
|
64
|
+
`.status` for HTTP errors).
|
|
65
|
+
|
|
66
|
+
## Versioning & stability
|
|
67
|
+
|
|
68
|
+
Tracks the public `/api/v1` contract; changes are additive within v1. See the
|
|
69
|
+
backend's [API stability policy](https://github.com/JSONbored/metagraphed/blob/main/docs/api-stability.md).
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Publishing `metagraphed` to PyPI
|
|
2
|
+
|
|
3
|
+
Releases go out via **OIDC Trusted Publishing** — no API token is ever created
|
|
4
|
+
or stored. The `.github/workflows/publish-python.yml` workflow builds the wheel +
|
|
5
|
+
sdist in an unprivileged job and publishes from a separate privileged job, so no
|
|
6
|
+
third-party code runs while the OIDC token is live.
|
|
7
|
+
|
|
8
|
+
## One-time bootstrap (owner, no placeholder upload needed)
|
|
9
|
+
|
|
10
|
+
1. **Confirm the project name is free** at <https://pypi.org/project/metagraphed/>
|
|
11
|
+
(a 404 means available). If it's taken, change `[project].name` in
|
|
12
|
+
`python/pyproject.toml`, the `pip install` command in the README, and the
|
|
13
|
+
PyPI Project Name field below to e.g. `metagraphed-client`.
|
|
14
|
+
|
|
15
|
+
2. **Add a PyPI pending publisher** at
|
|
16
|
+
<https://pypi.org/manage/account/publishing/> → "Add a new pending publisher"
|
|
17
|
+
with EXACTLY:
|
|
18
|
+
- PyPI Project Name: `metagraphed`
|
|
19
|
+
- Owner: `JSONbored`
|
|
20
|
+
- Repository name: `metagraphed`
|
|
21
|
+
- Workflow name: `publish-python.yml`
|
|
22
|
+
- Environment name: `pypi-production`
|
|
23
|
+
|
|
24
|
+
3. **Create the GitHub Environment** in repo Settings → Environments → New
|
|
25
|
+
environment named EXACTLY `pypi-production` (optionally add required reviewers
|
|
26
|
+
for a manual approval gate before each publish).
|
|
27
|
+
|
|
28
|
+
That's it — no token, no bootstrap upload. The pending publisher converts to a
|
|
29
|
+
normal Trusted Publisher on the first successful publish.
|
|
30
|
+
|
|
31
|
+
## Cutting a release
|
|
32
|
+
|
|
33
|
+
1. Bump `[project].version` in `python/pyproject.toml` (strict semver, no `v`
|
|
34
|
+
prefix) on `main`.
|
|
35
|
+
2. Actions → **Publish Python SDK** → Run workflow.
|
|
36
|
+
|
|
37
|
+
The release gate (`scripts/validate-python-release.sh`) refuses to run off
|
|
38
|
+
`main`, requires strict semver, and aborts if the git tag `python-v<version>` or
|
|
39
|
+
the PyPI version already exists.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# metagraphed (Python)
|
|
2
|
+
|
|
3
|
+
Thin, dependency-free Python client for **metagraphed** — the operational +
|
|
4
|
+
integration registry for Bittensor subnets at `https://api.metagraph.sh`.
|
|
5
|
+
|
|
6
|
+
It mirrors the [npm client](https://www.npmjs.com/package/@jsonbored/metagraphed):
|
|
7
|
+
one generic GET helper over the uniform, read-only API surface, returning the
|
|
8
|
+
parsed `{ ok, schema_version, data, meta }` envelope. Stdlib only — no transitive
|
|
9
|
+
dependencies.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install metagraphed
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from metagraphed import MetagraphedClient, metagraphed_fetch
|
|
21
|
+
|
|
22
|
+
client = MetagraphedClient() # base_url defaults to https://api.metagraph.sh
|
|
23
|
+
|
|
24
|
+
# List subnets (query params; None values are dropped)
|
|
25
|
+
subnets = client.fetch(
|
|
26
|
+
"/api/v1/subnets",
|
|
27
|
+
query={"limit": 10, "sort": "completeness_score", "order": "desc"},
|
|
28
|
+
)
|
|
29
|
+
print(subnets["data"][0]["name"])
|
|
30
|
+
|
|
31
|
+
# One subnet by netuid (path params)
|
|
32
|
+
detail = client.fetch("/api/v1/subnets/{netuid}", path_params={"netuid": 7})
|
|
33
|
+
|
|
34
|
+
# Which subnets are buildable? (integration readiness lives in the agent catalog)
|
|
35
|
+
catalog = client.fetch("/api/v1/agent-catalog")
|
|
36
|
+
|
|
37
|
+
# Point at the frontend origin instead, ad hoc:
|
|
38
|
+
metagraphed_fetch("/api/v1/health", base_url="https://metagraph.sh")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every response is the standard envelope:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
{"ok": True, "schema_version": 1, "data": ..., "meta": {...}}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
On a network failure or non-2xx response, a `MetagraphedError` is raised (with
|
|
48
|
+
`.status` for HTTP errors).
|
|
49
|
+
|
|
50
|
+
## Versioning & stability
|
|
51
|
+
|
|
52
|
+
Tracks the public `/api/v1` contract; changes are additive within v1. See the
|
|
53
|
+
backend's [API stability policy](https://github.com/JSONbored/metagraphed/blob/main/docs/api-stability.md).
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "metagraphed"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Thin Python client for metagraphed — the operational + integration registry for Bittensor subnets (api.metagraph.sh)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "JSONbored" }]
|
|
13
|
+
keywords = ["bittensor", "metagraph", "subnet", "registry", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://metagraph.sh"
|
|
23
|
+
Repository = "https://github.com/JSONbored/metagraphed"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/metagraphed"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""metagraphed — thin Python client for the Bittensor subnet registry API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
MetagraphedClient,
|
|
6
|
+
MetagraphedError,
|
|
7
|
+
metagraphed_fetch,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DEFAULT_BASE_URL",
|
|
13
|
+
"MetagraphedClient",
|
|
14
|
+
"MetagraphedError",
|
|
15
|
+
"metagraphed_fetch",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Thin Python client for the metagraphed API (https://api.metagraph.sh).
|
|
2
|
+
|
|
3
|
+
Dependency-free (stdlib ``urllib`` only). Mirrors the generated TypeScript
|
|
4
|
+
client: one generic GET helper over the uniform, read-only API surface,
|
|
5
|
+
returning the parsed ``{ ok, schema_version, data, meta }`` envelope as a dict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any, Mapping, Optional
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.metagraph.sh"
|
|
18
|
+
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MetagraphedError(Exception):
|
|
22
|
+
"""Raised on a network failure or a non-2xx HTTP response."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, *, status: Optional[int] = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status = status
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _interpolate(path: str, path_params: Optional[Mapping[str, Any]]) -> str:
|
|
30
|
+
params = path_params or {}
|
|
31
|
+
|
|
32
|
+
def repl(match: "re.Match[str]") -> str:
|
|
33
|
+
name = match.group(1)
|
|
34
|
+
if name not in params or params[name] is None:
|
|
35
|
+
raise MetagraphedError(f"Missing path parameter: {name}")
|
|
36
|
+
return urllib.parse.quote(str(params[name]), safe="")
|
|
37
|
+
|
|
38
|
+
return _PATH_PARAM.sub(repl, path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def metagraphed_fetch(
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
45
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
46
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
47
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""GET ``path`` against metagraphed and return the parsed JSON envelope.
|
|
51
|
+
|
|
52
|
+
``path`` may contain ``{name}`` segments filled from ``path_params``.
|
|
53
|
+
``query`` values that are ``None`` are dropped. Raises
|
|
54
|
+
:class:`MetagraphedError` on a network failure or a non-2xx response.
|
|
55
|
+
"""
|
|
56
|
+
url = base_url.rstrip("/") + _interpolate(path, path_params)
|
|
57
|
+
if query:
|
|
58
|
+
pairs = [(key, value) for key, value in query.items() if value is not None]
|
|
59
|
+
if pairs:
|
|
60
|
+
url += "?" + urllib.parse.urlencode(pairs, doseq=True)
|
|
61
|
+
|
|
62
|
+
request = urllib.request.Request(url, method="GET")
|
|
63
|
+
request.add_header("Accept", "application/json")
|
|
64
|
+
for key, value in (headers or {}).items():
|
|
65
|
+
request.add_header(key, value)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
69
|
+
body = response.read().decode("utf-8")
|
|
70
|
+
except urllib.error.HTTPError as error:
|
|
71
|
+
raise MetagraphedError(
|
|
72
|
+
f"GET {url} failed: HTTP {error.code}", status=error.code
|
|
73
|
+
) from error
|
|
74
|
+
except urllib.error.URLError as error:
|
|
75
|
+
raise MetagraphedError(f"GET {url} failed: {error.reason}") from error
|
|
76
|
+
|
|
77
|
+
return json.loads(body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MetagraphedClient:
|
|
81
|
+
"""Convenience wrapper binding a ``base_url`` + default ``timeout``."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 30.0
|
|
85
|
+
) -> None:
|
|
86
|
+
self.base_url = base_url
|
|
87
|
+
self.timeout = timeout
|
|
88
|
+
|
|
89
|
+
def fetch(
|
|
90
|
+
self,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
94
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
95
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""GET ``path`` using this client's ``base_url`` + ``timeout``."""
|
|
98
|
+
return metagraphed_fetch(
|
|
99
|
+
path,
|
|
100
|
+
base_url=self.base_url,
|
|
101
|
+
path_params=path_params,
|
|
102
|
+
query=query,
|
|
103
|
+
headers=headers,
|
|
104
|
+
timeout=self.timeout,
|
|
105
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Hermetic tests for the metagraphed client (urllib mocked, no network)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
import urllib.error
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from metagraphed import MetagraphedClient, MetagraphedError, metagraphed_fetch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _FakeResponse:
|
|
12
|
+
def __init__(self, payload):
|
|
13
|
+
self._body = json.dumps(payload).encode("utf-8")
|
|
14
|
+
|
|
15
|
+
def read(self):
|
|
16
|
+
return self._body
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, *exc):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClientTest(unittest.TestCase):
|
|
26
|
+
def test_interpolates_path_params_and_sets_accept(self):
|
|
27
|
+
captured = {}
|
|
28
|
+
|
|
29
|
+
def fake_urlopen(request, timeout=None):
|
|
30
|
+
captured["url"] = request.full_url
|
|
31
|
+
captured["accept"] = request.get_header("Accept")
|
|
32
|
+
return _FakeResponse({"ok": True, "data": {"netuid": 7}})
|
|
33
|
+
|
|
34
|
+
with mock.patch("urllib.request.urlopen", fake_urlopen):
|
|
35
|
+
out = metagraphed_fetch(
|
|
36
|
+
"/api/v1/subnets/{netuid}", path_params={"netuid": 7}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.assertEqual(captured["url"], "https://api.metagraph.sh/api/v1/subnets/7")
|
|
40
|
+
self.assertEqual(captured["accept"], "application/json")
|
|
41
|
+
self.assertEqual(out["data"]["netuid"], 7)
|
|
42
|
+
|
|
43
|
+
def test_missing_path_param_raises(self):
|
|
44
|
+
with self.assertRaises(MetagraphedError):
|
|
45
|
+
metagraphed_fetch("/api/v1/subnets/{netuid}")
|
|
46
|
+
|
|
47
|
+
def test_drops_none_query_values_and_encodes(self):
|
|
48
|
+
captured = {}
|
|
49
|
+
|
|
50
|
+
def fake_urlopen(request, timeout=None):
|
|
51
|
+
captured["url"] = request.full_url
|
|
52
|
+
return _FakeResponse({"ok": True})
|
|
53
|
+
|
|
54
|
+
with mock.patch("urllib.request.urlopen", fake_urlopen):
|
|
55
|
+
metagraphed_fetch(
|
|
56
|
+
"/api/v1/search",
|
|
57
|
+
query={"q": "image gen", "cursor": None, "limit": 5},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.assertIn("q=image+gen", captured["url"])
|
|
61
|
+
self.assertIn("limit=5", captured["url"])
|
|
62
|
+
self.assertNotIn("cursor", captured["url"])
|
|
63
|
+
|
|
64
|
+
def test_base_url_override(self):
|
|
65
|
+
captured = {}
|
|
66
|
+
|
|
67
|
+
def fake_urlopen(request, timeout=None):
|
|
68
|
+
captured["url"] = request.full_url
|
|
69
|
+
return _FakeResponse({"ok": True})
|
|
70
|
+
|
|
71
|
+
with mock.patch("urllib.request.urlopen", fake_urlopen):
|
|
72
|
+
MetagraphedClient(base_url="https://metagraph.sh").fetch("/api/v1/health")
|
|
73
|
+
|
|
74
|
+
self.assertTrue(
|
|
75
|
+
captured["url"].startswith("https://metagraph.sh/api/v1/health")
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def test_http_error_becomes_metagraphed_error(self):
|
|
79
|
+
def fake_urlopen(request, timeout=None):
|
|
80
|
+
raise urllib.error.HTTPError(request.full_url, 404, "Not Found", {}, None)
|
|
81
|
+
|
|
82
|
+
with mock.patch("urllib.request.urlopen", fake_urlopen):
|
|
83
|
+
with self.assertRaises(MetagraphedError) as ctx:
|
|
84
|
+
metagraphed_fetch("/api/v1/subnets/{netuid}", path_params={"netuid": 9999})
|
|
85
|
+
self.assertEqual(ctx.exception.status, 404)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|