humanrpc 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.
- humanrpc/__init__.py +8 -0
- humanrpc/__main__.py +145 -0
- humanrpc/client/__init__.py +0 -0
- humanrpc/client/client.py +46 -0
- humanrpc/client/endpoint.py +119 -0
- humanrpc/models/__init__.py +0 -0
- humanrpc/models/endpoint.py +14 -0
- humanrpc/models/request.py +13 -0
- humanrpc/models/response.py +33 -0
- humanrpc/server/__init__.py +0 -0
- humanrpc/server/app.py +37 -0
- humanrpc/server/manual.py +71 -0
- humanrpc/server/routes.py +110 -0
- humanrpc/server/state.py +52 -0
- humanrpc/server/ws.py +252 -0
- humanrpc-0.1.0.dist-info/METADATA +66 -0
- humanrpc-0.1.0.dist-info/RECORD +19 -0
- humanrpc-0.1.0.dist-info/WHEEL +4 -0
- humanrpc-0.1.0.dist-info/entry_points.txt +3 -0
humanrpc/__init__.py
ADDED
humanrpc/__main__.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from rich import print
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from humanrpc.server.app import create_app
|
|
13
|
+
|
|
14
|
+
cli = typer.Typer(help="HumanRPC CLI")
|
|
15
|
+
|
|
16
|
+
inspect_cli = typer.Typer(help="Inspect server state")
|
|
17
|
+
respond_cli = typer.Typer(help="Manually respond to requests")
|
|
18
|
+
|
|
19
|
+
cli.add_typer(inspect_cli, name="inspect")
|
|
20
|
+
cli.add_typer(respond_cli, name="respond")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
def serve(
|
|
25
|
+
host: str = "127.0.0.1",
|
|
26
|
+
port: int = 1078,
|
|
27
|
+
no_static: bool = False,
|
|
28
|
+
):
|
|
29
|
+
app = create_app(
|
|
30
|
+
serve_static=not no_static,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
uvicorn.run(
|
|
34
|
+
app,
|
|
35
|
+
host=host,
|
|
36
|
+
port=port,
|
|
37
|
+
reload=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cli.command("static-path")
|
|
42
|
+
def static_path():
|
|
43
|
+
path = files("humanrpc").joinpath("static")
|
|
44
|
+
print(Path(path))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@inspect_cli.command("endpoints")
|
|
48
|
+
def inspect_endpoints(
|
|
49
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
50
|
+
):
|
|
51
|
+
r = httpx.get(f"{endpoint}/endpoints")
|
|
52
|
+
data = r.json()
|
|
53
|
+
|
|
54
|
+
table = Table(title="Registered Endpoints")
|
|
55
|
+
table.add_column("Name")
|
|
56
|
+
|
|
57
|
+
for name in data.keys():
|
|
58
|
+
table.add_row(name)
|
|
59
|
+
|
|
60
|
+
print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@inspect_cli.command("request")
|
|
64
|
+
def inspect_request(
|
|
65
|
+
request_id: str,
|
|
66
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
67
|
+
):
|
|
68
|
+
r = httpx.get(f"{endpoint}/requests/{request_id}")
|
|
69
|
+
data = r.json()
|
|
70
|
+
|
|
71
|
+
print(data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@inspect_cli.command("pending")
|
|
75
|
+
def inspect_pending(
|
|
76
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
77
|
+
):
|
|
78
|
+
r = httpx.get(f"{endpoint}/requests/pending")
|
|
79
|
+
data = r.json()
|
|
80
|
+
|
|
81
|
+
print(data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@respond_cli.command("success")
|
|
85
|
+
def respond_success(
|
|
86
|
+
request_id: str,
|
|
87
|
+
payload: str,
|
|
88
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
89
|
+
):
|
|
90
|
+
data = json.loads(payload)
|
|
91
|
+
|
|
92
|
+
httpx.post(
|
|
93
|
+
f"{endpoint}/respond/success/{request_id}",
|
|
94
|
+
json=data,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
print("[green]Response sent (success)[/green]")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respond_cli.command("exception")
|
|
101
|
+
def respond_exception(
|
|
102
|
+
request_id: str,
|
|
103
|
+
exception_type: str,
|
|
104
|
+
payload: str,
|
|
105
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
106
|
+
):
|
|
107
|
+
data = json.loads(payload)
|
|
108
|
+
|
|
109
|
+
httpx.post(
|
|
110
|
+
f"{endpoint}/respond/exception/{request_id}",
|
|
111
|
+
params={"exception_type": exception_type},
|
|
112
|
+
json=data,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
print(
|
|
116
|
+
f"[yellow]Response sent (exception: {exception_type})[/yellow]"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@respond_cli.command("timeout")
|
|
121
|
+
def respond_timeout(
|
|
122
|
+
request_id: str,
|
|
123
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
124
|
+
):
|
|
125
|
+
httpx.post(f"{endpoint}/respond/timeout/{request_id}")
|
|
126
|
+
|
|
127
|
+
print("[blue]Response sent (timeout)[/blue]")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@respond_cli.command("ignore")
|
|
131
|
+
def respond_ignore(
|
|
132
|
+
request_id: str,
|
|
133
|
+
endpoint: str = "http://127.0.0.1:1078",
|
|
134
|
+
):
|
|
135
|
+
httpx.post(f"{endpoint}/respond/ignore/{request_id}")
|
|
136
|
+
|
|
137
|
+
print("[dim]Response sent (ignore)[/dim]")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main():
|
|
141
|
+
cli()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from humanrpc.client.endpoint import Endpoint
|
|
4
|
+
from humanrpc.models.endpoint import (
|
|
5
|
+
EndpointDefinition,
|
|
6
|
+
ExceptionDefinition,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HumanRPCClient:
|
|
11
|
+
def __init__(self, base_url: str = "http://127.0.0.1:1078"):
|
|
12
|
+
self.base_url = base_url
|
|
13
|
+
|
|
14
|
+
def endpoint(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
name: str,
|
|
18
|
+
input_model,
|
|
19
|
+
response_model,
|
|
20
|
+
exceptions=None,
|
|
21
|
+
) -> Endpoint:
|
|
22
|
+
exceptions = exceptions or []
|
|
23
|
+
|
|
24
|
+
definition = EndpointDefinition(
|
|
25
|
+
name=name,
|
|
26
|
+
input_schema=input_model.model_json_schema(),
|
|
27
|
+
response_schema=response_model.model_json_schema(),
|
|
28
|
+
exceptions=[
|
|
29
|
+
ExceptionDefinition(name=exc.__name__)
|
|
30
|
+
for exc in exceptions
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
httpx.post(
|
|
35
|
+
f"{self.base_url}/endpoints",
|
|
36
|
+
json=definition.model_dump(),
|
|
37
|
+
timeout=5.0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return Endpoint(
|
|
41
|
+
name=name,
|
|
42
|
+
input_model=input_model,
|
|
43
|
+
response_model=response_model,
|
|
44
|
+
exceptions=exceptions,
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import httpx
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import websockets
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Endpoint:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
name: str,
|
|
16
|
+
input_model,
|
|
17
|
+
response_model,
|
|
18
|
+
exceptions: list[type[Exception]],
|
|
19
|
+
base_url: str,
|
|
20
|
+
):
|
|
21
|
+
self.name = name
|
|
22
|
+
self.input_model = input_model
|
|
23
|
+
self.response_model = response_model
|
|
24
|
+
self.exceptions = exceptions
|
|
25
|
+
self.base_url = base_url
|
|
26
|
+
|
|
27
|
+
async def aask(self, request: BaseModel):
|
|
28
|
+
payload = request.model_dump()
|
|
29
|
+
request_id = str(uuid.uuid4())
|
|
30
|
+
|
|
31
|
+
# Use AsyncClient to avoid blocking the loop
|
|
32
|
+
async with httpx.AsyncClient() as client:
|
|
33
|
+
await client.post(
|
|
34
|
+
f"{self.base_url}/requests",
|
|
35
|
+
json={
|
|
36
|
+
"request_id": request_id,
|
|
37
|
+
"endpoint_name": self.name,
|
|
38
|
+
"payload": payload,
|
|
39
|
+
},
|
|
40
|
+
timeout=5.0,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ws_url = self.base_url.replace("http", "ws")
|
|
44
|
+
ws_url = f"{ws_url}/ws/requests/{request_id}"
|
|
45
|
+
|
|
46
|
+
async with websockets.connect(ws_url) as ws:
|
|
47
|
+
await ws.send("ready")
|
|
48
|
+
|
|
49
|
+
while True:
|
|
50
|
+
msg = await ws.recv()
|
|
51
|
+
record = json.loads(msg)
|
|
52
|
+
|
|
53
|
+
response = record.get("response")
|
|
54
|
+
if not response:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
kind = response.get("kind")
|
|
58
|
+
|
|
59
|
+
if kind == "success":
|
|
60
|
+
return self.response_model.model_validate(response["payload"])
|
|
61
|
+
|
|
62
|
+
if kind == "exception":
|
|
63
|
+
exc_type = response["exception_type"]
|
|
64
|
+
|
|
65
|
+
for exc in self.exceptions:
|
|
66
|
+
if exc.__name__ == exc_type:
|
|
67
|
+
raise exc(**response["payload"])
|
|
68
|
+
|
|
69
|
+
raise Exception(f"Unknown exception: {exc_type}")
|
|
70
|
+
|
|
71
|
+
if kind == "timeout":
|
|
72
|
+
raise TimeoutError()
|
|
73
|
+
|
|
74
|
+
if kind == "ignore":
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def ask(self, request: BaseModel):
|
|
78
|
+
request_id = str(uuid.uuid4())
|
|
79
|
+
|
|
80
|
+
with httpx.Client() as client:
|
|
81
|
+
client.post(
|
|
82
|
+
f"{self.base_url}/requests",
|
|
83
|
+
json={
|
|
84
|
+
"request_id": request_id,
|
|
85
|
+
"endpoint_name": self.name,
|
|
86
|
+
"payload": request.model_dump(),
|
|
87
|
+
},
|
|
88
|
+
timeout=5.0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Poll for resolution
|
|
92
|
+
while True:
|
|
93
|
+
r = client.get(f"{self.base_url}/requests/{request_id}")
|
|
94
|
+
if r.status_code == 200:
|
|
95
|
+
record = r.json()
|
|
96
|
+
if record.get("status") == "completed":
|
|
97
|
+
response = record.get("response")
|
|
98
|
+
if not response:
|
|
99
|
+
raise Exception("Request completed with no response payload.")
|
|
100
|
+
|
|
101
|
+
kind = response.get("kind")
|
|
102
|
+
|
|
103
|
+
if kind == "success":
|
|
104
|
+
return self.response_model.model_validate(response["payload"])
|
|
105
|
+
|
|
106
|
+
if kind == "exception":
|
|
107
|
+
exc_type = response["exception_type"]
|
|
108
|
+
for exc in self.exceptions:
|
|
109
|
+
if exc.__name__ == exc_type:
|
|
110
|
+
raise exc(**response["payload"])
|
|
111
|
+
raise Exception(f"Unknown exception: {exc_type}")
|
|
112
|
+
|
|
113
|
+
if kind == "timeout":
|
|
114
|
+
raise TimeoutError()
|
|
115
|
+
|
|
116
|
+
if kind == "ignore":
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
time.sleep(0.2)
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExceptionDefinition(BaseModel):
|
|
5
|
+
name: str
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EndpointDefinition(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
|
|
11
|
+
input_schema: dict
|
|
12
|
+
response_schema: dict
|
|
13
|
+
|
|
14
|
+
exceptions: list[ExceptionDefinition] = Field(default_factory=list)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RequestEnvelope(BaseModel):
|
|
7
|
+
request_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
8
|
+
|
|
9
|
+
endpoint_name: str
|
|
10
|
+
|
|
11
|
+
payload: dict
|
|
12
|
+
|
|
13
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SuccessResponse(BaseModel):
|
|
7
|
+
kind: Literal["success"] = "success"
|
|
8
|
+
|
|
9
|
+
payload: dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExceptionResponse(BaseModel):
|
|
13
|
+
kind: Literal["exception"] = "exception"
|
|
14
|
+
|
|
15
|
+
exception_type: str
|
|
16
|
+
|
|
17
|
+
payload: dict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TimeoutResponse(BaseModel):
|
|
21
|
+
kind: Literal["timeout"] = "timeout"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IgnoreResponse(BaseModel):
|
|
25
|
+
kind: Literal["ignore"] = "ignore"
|
|
26
|
+
|
|
27
|
+
ResponseEnvelope = (
|
|
28
|
+
SuccessResponse
|
|
29
|
+
| ExceptionResponse
|
|
30
|
+
| TimeoutResponse
|
|
31
|
+
| IgnoreResponse
|
|
32
|
+
)
|
|
33
|
+
|
|
File without changes
|
humanrpc/server/app.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.staticfiles import StaticFiles
|
|
5
|
+
|
|
6
|
+
from starlette.routing import Mount
|
|
7
|
+
|
|
8
|
+
from humanrpc.server.routes import router as core_router
|
|
9
|
+
from humanrpc.server.manual import router as manual_router
|
|
10
|
+
from humanrpc.server.ws import router as ws_router
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_app(*, serve_static: bool = True) -> FastAPI:
|
|
14
|
+
app = FastAPI()
|
|
15
|
+
|
|
16
|
+
app.include_router(ws_router)
|
|
17
|
+
app.include_router(core_router)
|
|
18
|
+
app.include_router(manual_router)
|
|
19
|
+
|
|
20
|
+
if serve_static:
|
|
21
|
+
static_dir = files("humanrpc").joinpath("static")
|
|
22
|
+
|
|
23
|
+
app.mount(
|
|
24
|
+
"/",
|
|
25
|
+
StaticFiles(
|
|
26
|
+
directory=str(static_dir),
|
|
27
|
+
html=True,
|
|
28
|
+
),
|
|
29
|
+
name="static",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return app
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
app = create_app()
|
|
36
|
+
|
|
37
|
+
app.routes.sort(key=lambda r: 1 if isinstance(r, Mount) and r.path == "/" else 0)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
|
|
4
|
+
from humanrpc.server.state import state
|
|
5
|
+
from humanrpc.models.response import (
|
|
6
|
+
SuccessResponse,
|
|
7
|
+
ExceptionResponse,
|
|
8
|
+
TimeoutResponse,
|
|
9
|
+
IgnoreResponse,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _complete(request_id: str):
|
|
16
|
+
await state.notify(request_id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/respond/success/{request_id}")
|
|
20
|
+
async def respond_success(request_id: str, payload: dict):
|
|
21
|
+
record = state.requests.get(request_id)
|
|
22
|
+
|
|
23
|
+
if record:
|
|
24
|
+
record.status = "completed"
|
|
25
|
+
record.response = SuccessResponse(payload=payload)
|
|
26
|
+
record.responded_at = datetime.utcnow()
|
|
27
|
+
await _complete(request_id)
|
|
28
|
+
|
|
29
|
+
return {"ok": True}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/respond/exception/{request_id}")
|
|
33
|
+
async def respond_exception(request_id: str, exception_type: str, payload: dict):
|
|
34
|
+
record = state.requests.get(request_id)
|
|
35
|
+
|
|
36
|
+
if record:
|
|
37
|
+
record.status = "completed"
|
|
38
|
+
record.response = ExceptionResponse(
|
|
39
|
+
exception_type=exception_type,
|
|
40
|
+
payload=payload,
|
|
41
|
+
)
|
|
42
|
+
record.responded_at = datetime.utcnow()
|
|
43
|
+
await _complete(request_id)
|
|
44
|
+
|
|
45
|
+
return {"ok": True}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post("/respond/timeout/{request_id}")
|
|
49
|
+
async def respond_timeout(request_id: str):
|
|
50
|
+
record = state.requests.get(request_id)
|
|
51
|
+
|
|
52
|
+
if record:
|
|
53
|
+
record.status = "completed"
|
|
54
|
+
record.response = TimeoutResponse()
|
|
55
|
+
record.responded_at = datetime.utcnow()
|
|
56
|
+
await _complete(request_id)
|
|
57
|
+
|
|
58
|
+
return {"ok": True}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/respond/ignore/{request_id}")
|
|
62
|
+
async def respond_ignore(request_id: str):
|
|
63
|
+
record = state.requests.get(request_id)
|
|
64
|
+
|
|
65
|
+
if record:
|
|
66
|
+
record.status = "completed"
|
|
67
|
+
record.response = IgnoreResponse()
|
|
68
|
+
record.responded_at = datetime.utcnow()
|
|
69
|
+
await _complete(request_id)
|
|
70
|
+
|
|
71
|
+
return {"ok": True}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from fastapi.encoders import jsonable_encoder
|
|
4
|
+
from humanrpc.models.endpoint import EndpointDefinition
|
|
5
|
+
from humanrpc.models.request import RequestEnvelope
|
|
6
|
+
from humanrpc.server.state import state, RequestRecord
|
|
7
|
+
from humanrpc.models.response import (
|
|
8
|
+
SuccessResponse,
|
|
9
|
+
ExceptionResponse,
|
|
10
|
+
TimeoutResponse,
|
|
11
|
+
IgnoreResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.post("/endpoints")
|
|
18
|
+
async def register_endpoint(endpoint: EndpointDefinition):
|
|
19
|
+
state.endpoints[endpoint.name] = endpoint
|
|
20
|
+
return {"status": "ok"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/endpoints")
|
|
24
|
+
async def list_endpoints():
|
|
25
|
+
return state.endpoints
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/requests")
|
|
29
|
+
async def submit_request(request: RequestEnvelope):
|
|
30
|
+
if request.endpoint_name not in state.endpoints:
|
|
31
|
+
raise HTTPException(status_code=404, detail="Unknown endpoint")
|
|
32
|
+
|
|
33
|
+
state.requests[request.request_id] = RequestRecord(request=request)
|
|
34
|
+
|
|
35
|
+
return {"status": "queued", "request_id": request.request_id}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/requests/pending")
|
|
39
|
+
async def list_pending():
|
|
40
|
+
return jsonable_encoder([
|
|
41
|
+
r.model_dump(mode="json")
|
|
42
|
+
for r in state.requests.values()
|
|
43
|
+
if r.status == "pending"
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/requests")
|
|
48
|
+
async def list_requests():
|
|
49
|
+
return jsonable_encoder([
|
|
50
|
+
r.model_dump(mode="json")
|
|
51
|
+
for r in state.requests.values()
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/requests/{request_id}")
|
|
56
|
+
async def get_request(request_id: str):
|
|
57
|
+
record = state.requests.get(request_id)
|
|
58
|
+
|
|
59
|
+
if not record:
|
|
60
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
61
|
+
|
|
62
|
+
# IMPORTANT: JSON-safe (fixes datetime crash)
|
|
63
|
+
return jsonable_encoder(record.model_dump(mode="json"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/requests/{request_id}/resolve")
|
|
67
|
+
@router.post("/api/requests/{request_id}/resolve")
|
|
68
|
+
async def resolve_request(request_id: str, data: dict):
|
|
69
|
+
record = state.requests.get(request_id)
|
|
70
|
+
if not record:
|
|
71
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
72
|
+
|
|
73
|
+
status = data.get("status", "completed")
|
|
74
|
+
response = data.get("response")
|
|
75
|
+
|
|
76
|
+
# Record status must be "completed" for client to stop waiting
|
|
77
|
+
record.status = "completed"
|
|
78
|
+
|
|
79
|
+
# Map frontend status to the correct ResponseEnvelope
|
|
80
|
+
if status == "rejected":
|
|
81
|
+
record.response = ExceptionResponse(
|
|
82
|
+
exception_type="Exception",
|
|
83
|
+
payload={"message": "Rejected by human operator"}
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
# Ensure we construct the proper ResponseEnvelope subclass
|
|
87
|
+
if isinstance(response, dict) and "kind" in response:
|
|
88
|
+
kind = response.get("kind")
|
|
89
|
+
if kind == "success":
|
|
90
|
+
record.response = SuccessResponse(payload=response.get("payload", {}))
|
|
91
|
+
elif kind == "exception":
|
|
92
|
+
record.response = ExceptionResponse(
|
|
93
|
+
exception_type=response.get("exception_type", "Exception"),
|
|
94
|
+
payload=response.get("payload", {})
|
|
95
|
+
)
|
|
96
|
+
elif kind == "timeout":
|
|
97
|
+
record.response = TimeoutResponse()
|
|
98
|
+
elif kind == "ignore":
|
|
99
|
+
record.response = IgnoreResponse()
|
|
100
|
+
else:
|
|
101
|
+
record.response = SuccessResponse(payload=response)
|
|
102
|
+
else:
|
|
103
|
+
record.response = SuccessResponse(payload=response)
|
|
104
|
+
|
|
105
|
+
record.responded_at = datetime.utcnow()
|
|
106
|
+
|
|
107
|
+
# Notify all listeners (including client polling/WebSockets)
|
|
108
|
+
await state.notify(request_id)
|
|
109
|
+
|
|
110
|
+
return {"status": "ok"}
|
humanrpc/server/state.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Literal, Callable, Awaitable
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from humanrpc.models.endpoint import EndpointDefinition
|
|
6
|
+
from humanrpc.models.request import RequestEnvelope
|
|
7
|
+
from humanrpc.models.response import ResponseEnvelope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestRecord(BaseModel):
|
|
11
|
+
request: RequestEnvelope
|
|
12
|
+
status: Literal["pending", "completed"] = "pending"
|
|
13
|
+
response: ResponseEnvelope | None = None
|
|
14
|
+
responded_at: datetime | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Listener = Callable[[RequestRecord], Awaitable[None]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class State:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.endpoints: dict[str, EndpointDefinition] = {}
|
|
23
|
+
self.requests: dict[str, RequestRecord] = {}
|
|
24
|
+
self.listeners: dict[str, list[Listener]] = {}
|
|
25
|
+
|
|
26
|
+
def add_listener(self, request_id: str, cb: Listener):
|
|
27
|
+
self.listeners.setdefault(request_id, []).append(cb)
|
|
28
|
+
|
|
29
|
+
def remove_listener(self, request_id: str, cb: Listener):
|
|
30
|
+
if request_id in self.listeners:
|
|
31
|
+
try:
|
|
32
|
+
self.listeners[request_id].remove(cb)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
if not self.listeners[request_id]:
|
|
36
|
+
self.listeners.pop(request_id, None)
|
|
37
|
+
|
|
38
|
+
def remove_listeners(self, request_id: str):
|
|
39
|
+
self.listeners.pop(request_id, None)
|
|
40
|
+
|
|
41
|
+
async def notify(self, request_id: str):
|
|
42
|
+
record = self.requests.get(request_id)
|
|
43
|
+
if not record:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
for cb in list(self.listeners.get(request_id, [])):
|
|
47
|
+
try:
|
|
48
|
+
await cb(record)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
state = State()
|
humanrpc/server/ws.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
4
|
+
|
|
5
|
+
from humanrpc.server.state import state
|
|
6
|
+
from humanrpc.models.response import (
|
|
7
|
+
SuccessResponse,
|
|
8
|
+
ExceptionResponse,
|
|
9
|
+
TimeoutResponse,
|
|
10
|
+
IgnoreResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
# Keep track of active browser console dashboard WebSocket connections
|
|
16
|
+
active_consoles = set()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObservableDict(dict):
|
|
20
|
+
"""
|
|
21
|
+
A dictionary wrapper that executes a callback whenever a key-value
|
|
22
|
+
pair is added or updated, enabling automatic live notifications.
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self, initial_dict, on_set=None):
|
|
25
|
+
super().__init__(initial_dict)
|
|
26
|
+
self._on_set = on_set
|
|
27
|
+
|
|
28
|
+
def __setitem__(self, key, value):
|
|
29
|
+
is_new = key not in self
|
|
30
|
+
super().__setitem__(key, value)
|
|
31
|
+
if self._on_set:
|
|
32
|
+
self._on_set(key, value, is_new)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def broadcast_to_consoles(message):
|
|
36
|
+
"""Safely dispatches a JSON message to all open console dashboards."""
|
|
37
|
+
async def send_all():
|
|
38
|
+
for ws in list(active_consoles):
|
|
39
|
+
try:
|
|
40
|
+
await ws.send_json(message)
|
|
41
|
+
except Exception:
|
|
42
|
+
active_consoles.discard(ws)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
if loop.is_running():
|
|
47
|
+
loop.create_task(send_all())
|
|
48
|
+
except RuntimeError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_all_endpoints():
|
|
53
|
+
"""Helper to cleanly serialize registered schemas from state."""
|
|
54
|
+
endpoints = []
|
|
55
|
+
if hasattr(state, "endpoints"):
|
|
56
|
+
raw = state.endpoints.values() if isinstance(state.endpoints, dict) else state.endpoints
|
|
57
|
+
for e in raw:
|
|
58
|
+
if hasattr(e, "model_dump"):
|
|
59
|
+
endpoints.append(e.model_dump(mode="json"))
|
|
60
|
+
else:
|
|
61
|
+
endpoints.append(e)
|
|
62
|
+
return endpoints
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_all_requests():
|
|
66
|
+
"""Helper to cleanly serialize active/historical requests from state."""
|
|
67
|
+
requests = []
|
|
68
|
+
for r in state.requests.values():
|
|
69
|
+
if hasattr(r, "model_dump"):
|
|
70
|
+
requests.append(r.model_dump(mode="json"))
|
|
71
|
+
else:
|
|
72
|
+
requests.append(r)
|
|
73
|
+
return requests
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def on_request_set(request_id, record, is_new):
|
|
77
|
+
"""Triggered automatically whenever a request is registered or modified."""
|
|
78
|
+
event_type = "request_created" if is_new else "request_updated"
|
|
79
|
+
|
|
80
|
+
# Notify all consoles about the request change
|
|
81
|
+
broadcast_to_consoles({
|
|
82
|
+
"type": event_type,
|
|
83
|
+
"request": record.model_dump(mode="json") if hasattr(record, "model_dump") else record
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# If a new request is created, hook a listener to propagate updates
|
|
87
|
+
if is_new:
|
|
88
|
+
async def listener(rec):
|
|
89
|
+
broadcast_to_consoles({
|
|
90
|
+
"type": "request_updated",
|
|
91
|
+
"request": rec.model_dump(mode="json") if hasattr(rec, "model_dump") else rec
|
|
92
|
+
})
|
|
93
|
+
try:
|
|
94
|
+
state.add_listener(request_id, listener)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# 1. Bind listeners for any pre-existing requests on server startup
|
|
100
|
+
for req_id, rec in list(state.requests.items()):
|
|
101
|
+
async def legacy_listener(r):
|
|
102
|
+
broadcast_to_consoles({
|
|
103
|
+
"type": "request_updated",
|
|
104
|
+
"request": r.model_dump(mode="json") if hasattr(r, "model_dump") else r
|
|
105
|
+
})
|
|
106
|
+
try:
|
|
107
|
+
state.add_listener(req_id, legacy_listener)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# 2. Patch the requests dictionary to automatically observe future updates
|
|
112
|
+
state.requests = ObservableDict(state.requests, on_set=on_request_set)
|
|
113
|
+
|
|
114
|
+
# 3. Patch the endpoints collection (if dict/list) to notify consoles on updates
|
|
115
|
+
if hasattr(state, "endpoints"):
|
|
116
|
+
if isinstance(state.endpoints, dict):
|
|
117
|
+
def on_endpoint_set(key, val, is_new):
|
|
118
|
+
broadcast_to_consoles({
|
|
119
|
+
"type": "init",
|
|
120
|
+
"endpoints": get_all_endpoints(),
|
|
121
|
+
"requests": get_all_requests()
|
|
122
|
+
})
|
|
123
|
+
state.endpoints = ObservableDict(state.endpoints, on_set=on_endpoint_set)
|
|
124
|
+
elif isinstance(state.endpoints, list):
|
|
125
|
+
original_append = state.endpoints.append
|
|
126
|
+
def patched_append(item):
|
|
127
|
+
original_append(item)
|
|
128
|
+
broadcast_to_consoles({
|
|
129
|
+
"type": "init",
|
|
130
|
+
"endpoints": get_all_endpoints(),
|
|
131
|
+
"requests": get_all_requests()
|
|
132
|
+
})
|
|
133
|
+
state.endpoints.append = patched_append
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- 1. Global Console Dashboard WebSocket Endpoint ---
|
|
137
|
+
@router.websocket("/ws")
|
|
138
|
+
async def ws_console(websocket: WebSocket):
|
|
139
|
+
"""
|
|
140
|
+
Acts as the main real-time bridge for the browser dashboard.
|
|
141
|
+
Loads initial data on load, and listens for client updates.
|
|
142
|
+
"""
|
|
143
|
+
await websocket.accept()
|
|
144
|
+
active_consoles.add(websocket)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Load active dataset
|
|
148
|
+
await websocket.send_json({
|
|
149
|
+
"type": "init",
|
|
150
|
+
"endpoints": get_all_endpoints(),
|
|
151
|
+
"requests": get_all_requests()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
# Optionally receive human actions (e.g., resolutions over WebSockets)
|
|
156
|
+
data = await websocket.receive_json()
|
|
157
|
+
if data.get("type") in ("resolve", "respond"):
|
|
158
|
+
request_id = data.get("id") or data.get("request_id")
|
|
159
|
+
response = data.get("response")
|
|
160
|
+
status = data.get("status", "completed")
|
|
161
|
+
|
|
162
|
+
if request_id:
|
|
163
|
+
record = state.requests.get(request_id)
|
|
164
|
+
if record:
|
|
165
|
+
# Record status must be "completed" so that the waiting client resumes
|
|
166
|
+
record.status = "completed"
|
|
167
|
+
|
|
168
|
+
# Set proper ResponseEnvelope subclass values
|
|
169
|
+
if status == "rejected":
|
|
170
|
+
record.response = ExceptionResponse(
|
|
171
|
+
exception_type="Exception",
|
|
172
|
+
payload={"message": "Rejected by human operator"}
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
if isinstance(response, dict) and "kind" in response:
|
|
176
|
+
kind = response.get("kind")
|
|
177
|
+
if kind == "success":
|
|
178
|
+
record.response = SuccessResponse(payload=response.get("payload", {}))
|
|
179
|
+
elif kind == "exception":
|
|
180
|
+
record.response = ExceptionResponse(
|
|
181
|
+
exception_type=response.get("exception_type", "Exception"),
|
|
182
|
+
payload=response.get("payload", {})
|
|
183
|
+
)
|
|
184
|
+
elif kind == "timeout":
|
|
185
|
+
record.response = TimeoutResponse()
|
|
186
|
+
elif kind == "ignore":
|
|
187
|
+
record.response = IgnoreResponse()
|
|
188
|
+
else:
|
|
189
|
+
record.response = SuccessResponse(payload=response)
|
|
190
|
+
else:
|
|
191
|
+
record.response = SuccessResponse(payload=response)
|
|
192
|
+
|
|
193
|
+
record.responded_at = datetime.utcnow()
|
|
194
|
+
|
|
195
|
+
# Correctly dispatch notifications to registered listeners
|
|
196
|
+
await state.notify(request_id)
|
|
197
|
+
except WebSocketDisconnect:
|
|
198
|
+
pass
|
|
199
|
+
finally:
|
|
200
|
+
active_consoles.discard(websocket)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- 2. Client-Specific Blocking WebSocket Endpoint ---
|
|
204
|
+
@router.websocket("/ws/requests/{request_id}")
|
|
205
|
+
async def ws_request(request_id: str, websocket: WebSocket):
|
|
206
|
+
"""Matches standard client await loops."""
|
|
207
|
+
await websocket.accept()
|
|
208
|
+
|
|
209
|
+
record = state.requests.get(request_id)
|
|
210
|
+
if record and record.status == "completed":
|
|
211
|
+
await websocket.send_json(record.model_dump(mode="json"))
|
|
212
|
+
await websocket.close()
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
stop_event = asyncio.Event()
|
|
216
|
+
|
|
217
|
+
async def listener(rec):
|
|
218
|
+
try:
|
|
219
|
+
await websocket.send_json(rec.model_dump(mode="json"))
|
|
220
|
+
if rec.status == "completed":
|
|
221
|
+
stop_event.set()
|
|
222
|
+
except Exception:
|
|
223
|
+
stop_event.set()
|
|
224
|
+
|
|
225
|
+
state.add_listener(request_id, listener)
|
|
226
|
+
|
|
227
|
+
if record:
|
|
228
|
+
try:
|
|
229
|
+
await websocket.send_json(record.model_dump(mode="json"))
|
|
230
|
+
except Exception:
|
|
231
|
+
stop_event.set()
|
|
232
|
+
|
|
233
|
+
async def receive_loop():
|
|
234
|
+
try:
|
|
235
|
+
while not stop_event.is_set():
|
|
236
|
+
await websocket.receive_text()
|
|
237
|
+
except WebSocketDisconnect:
|
|
238
|
+
pass
|
|
239
|
+
finally:
|
|
240
|
+
stop_event.set()
|
|
241
|
+
|
|
242
|
+
receive_task = asyncio.create_task(receive_loop())
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
await stop_event.wait()
|
|
246
|
+
finally:
|
|
247
|
+
state.remove_listener(request_id, listener)
|
|
248
|
+
receive_task.cancel()
|
|
249
|
+
try:
|
|
250
|
+
await websocket.close()
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: humanrpc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Human-powered typed RPC endpoints for building systems before the real implementation exists.
|
|
5
|
+
Author: cvaz1306
|
|
6
|
+
Author-email: christophervaz160@gmail.com
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Dist: fastapi (>=0.136.3,<0.137.0)
|
|
13
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
14
|
+
Requires-Dist: pydantic (>=2.13.4,<3.0.0)
|
|
15
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
16
|
+
Requires-Dist: typer (>=0.26.6,<0.27.0)
|
|
17
|
+
Requires-Dist: uvicorn (>=0.48.0,<0.49.0)
|
|
18
|
+
Requires-Dist: websockets (>=16.0,<17.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# HumanRPC
|
|
22
|
+
|
|
23
|
+
HumanRPC is a human-powered RPC server for developing distributed systems before the real implementation exists.
|
|
24
|
+
|
|
25
|
+
Instead of calling an AI agent, microservice, planner, or capability, your application calls a typed endpoint. A human operator receives the request in a web UI, provides a response, and HumanRPC returns the result to the caller.
|
|
26
|
+
|
|
27
|
+
The goal is to enable rapid development of orchestration, routing, scheduling, memory, and workflow systems without waiting for every component to be implemented.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
* Pydantic-first API
|
|
32
|
+
* Typed request and response models
|
|
33
|
+
* Async and sync clients
|
|
34
|
+
* Human-powered endpoint implementations
|
|
35
|
+
* Exception simulation
|
|
36
|
+
* Timeout simulation
|
|
37
|
+
* Web UI for handling requests
|
|
38
|
+
* No external infrastructure required
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from humanrpc import Client
|
|
44
|
+
|
|
45
|
+
client = Client()
|
|
46
|
+
|
|
47
|
+
agent = client.endpoint(
|
|
48
|
+
name="agent",
|
|
49
|
+
input_model=ChatRequest,
|
|
50
|
+
response_model=ChatResponse,
|
|
51
|
+
exceptions=[
|
|
52
|
+
ValueError,
|
|
53
|
+
HTTPException,
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = await agent.aask(
|
|
58
|
+
ChatRequest(...)
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Status
|
|
63
|
+
|
|
64
|
+
Early development.
|
|
65
|
+
|
|
66
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
humanrpc/__init__.py,sha256=_AP5_HdH1yieYH5fIZhc1KV54i35x6Vjw3iNBBY-wJY,134
|
|
2
|
+
humanrpc/__main__.py,sha256=a2i2DGMG7NDhn2r_sdFAE5Ly61GHw5VJhOywzRFn5Ss,3011
|
|
3
|
+
humanrpc/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
humanrpc/client/client.py,sha256=-5PY0OcfwlAIe3K6xchjC6dpgmqkoeHdCXuRUj_uT_g,1188
|
|
5
|
+
humanrpc/client/endpoint.py,sha256=oXaP4Nybc9DxulDF5zeCAQ6_mD0Rd2bM-FhETyzZPJg,3873
|
|
6
|
+
humanrpc/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
humanrpc/models/endpoint.py,sha256=HW6jNe_w8OCnyc7d5Qrh2Z6oZEnfnNRNdtff34znyik,282
|
|
8
|
+
humanrpc/models/request.py,sha256=ElcQ7lH3uM5mQkUO4SMHMLhylg_mCkzlqzp0pn5G5pk,316
|
|
9
|
+
humanrpc/models/response.py,sha256=ZtJbbwtaknwjebSZnuj1sGZCqI1qmMsDF45AgCKZ8qI,578
|
|
10
|
+
humanrpc/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
humanrpc/server/app.py,sha256=MmWHf75Iu7n-Yk8IdukGQwHjsW0Nx8HY4CMc-4PVWj4,925
|
|
12
|
+
humanrpc/server/manual.py,sha256=cm6CY8FjV3HpyRWoHalLOviFHQQzuPsqmfEDrzqMXuw,1909
|
|
13
|
+
humanrpc/server/routes.py,sha256=XV_C_C3TA2cbeK5VQUT_jfhonmJNrVIp520xQiS4oY0,3601
|
|
14
|
+
humanrpc/server/state.py,sha256=1YBN8s2eN0s2O2oIjF1lxQhqKMOrvv-c-s90kwvx1ug,1648
|
|
15
|
+
humanrpc/server/ws.py,sha256=0nbDwaPeQq_SIv3W9QtDPIUWnh4BuagBmgJvesRBbs4,9216
|
|
16
|
+
humanrpc-0.1.0.dist-info/entry_points.txt,sha256=d6pWmG3REfFpvuQRMQHPDsODi8GwiCvLQQYF7120oLk,51
|
|
17
|
+
humanrpc-0.1.0.dist-info/METADATA,sha256=5cHDOsUR59XTPyiersnIgJprDiez4BTU7bf6oz5Cqbw,1871
|
|
18
|
+
humanrpc-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
19
|
+
humanrpc-0.1.0.dist-info/RECORD,,
|