ecp-runtime 0.2.9__tar.gz → 0.3.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.
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/.gitignore +5 -2
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/PKG-INFO +8 -1
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/README.md +7 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/pyproject.toml +1 -1
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/runner.py +82 -1
- ecp_runtime-0.3.0/tests/test_example_integrations.py +135 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/test_runner.py +45 -1
- ecp_runtime-0.2.9/tests/test_example_integrations.py +0 -51
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/__init__.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/cli.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/graders.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/manifest.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/reporter.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/src/ecp_runtime/trend.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/__init__.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/test_cli.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/test_graders.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/test_manifest.py +0 -0
- {ecp_runtime-0.2.9 → ecp_runtime-0.3.0}/tests/test_trend.py +0 -0
|
@@ -31,7 +31,7 @@ MANIFEST
|
|
|
31
31
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
32
|
*.manifest
|
|
33
33
|
*.spec
|
|
34
|
-
|
|
34
|
+
/specs/
|
|
35
35
|
# Installer logs
|
|
36
36
|
pip-log.txt
|
|
37
37
|
pip-delete-this-directory.txt
|
|
@@ -50,7 +50,10 @@ coverage.xml
|
|
|
50
50
|
.hypothesis/
|
|
51
51
|
.pytest_cache/
|
|
52
52
|
cover/
|
|
53
|
-
|
|
53
|
+
node_modules/
|
|
54
|
+
.github/agents
|
|
55
|
+
.github/prompts/
|
|
56
|
+
.specify/
|
|
54
57
|
# Translations
|
|
55
58
|
*.mo
|
|
56
59
|
*.pot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ecp-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The reference runtime for the Evaluation Context Protocol (ECP).
|
|
5
5
|
Project-URL: Homepage, https://github.com/evaluation-context-protocol/ecp
|
|
6
6
|
Project-URL: Documentation, https://evaluationcontextprotocol.io/
|
|
@@ -43,6 +43,13 @@ You can also run via module entrypoint:
|
|
|
43
43
|
python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
Manifest `target` values may be either a command for the default stdio transport
|
|
47
|
+
or an ECP Streamable HTTP endpoint:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
target: "http://127.0.0.1:8765/ecp"
|
|
51
|
+
```
|
|
52
|
+
|
|
46
53
|
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
47
54
|
|
|
48
55
|
```bash
|
|
@@ -25,6 +25,13 @@ You can also run via module entrypoint:
|
|
|
25
25
|
python -m ecp_runtime.cli run --manifest .\examples\langchain_demo\manifest.yaml
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Manifest `target` values may be either a command for the default stdio transport
|
|
29
|
+
or an ECP Streamable HTTP endpoint:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
target: "http://127.0.0.1:8765/ecp"
|
|
33
|
+
```
|
|
34
|
+
|
|
28
35
|
If your manifest includes `llm_judge`, set an API key and optional judge model:
|
|
29
36
|
|
|
30
37
|
```bash
|
|
@@ -14,6 +14,8 @@ import threading
|
|
|
14
14
|
import time
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from typing import Any, Dict, List, Optional
|
|
17
|
+
from urllib import error, request
|
|
18
|
+
from urllib.parse import urlparse
|
|
17
19
|
|
|
18
20
|
from .graders import evaluate_step
|
|
19
21
|
|
|
@@ -148,6 +150,75 @@ class AgentProcess:
|
|
|
148
150
|
return ""
|
|
149
151
|
|
|
150
152
|
|
|
153
|
+
class HTTPAgentClient:
|
|
154
|
+
"""JSON-RPC client for ECP Streamable HTTP endpoints."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, endpoint: str, rpc_timeout: float = 30.0):
|
|
157
|
+
self.endpoint = endpoint
|
|
158
|
+
self.rpc_timeout = rpc_timeout
|
|
159
|
+
|
|
160
|
+
def start(self):
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def stop(self):
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def send_rpc(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
167
|
+
if not params:
|
|
168
|
+
params = {}
|
|
169
|
+
|
|
170
|
+
payload = {
|
|
171
|
+
"jsonrpc": "2.0",
|
|
172
|
+
"method": method,
|
|
173
|
+
"params": params,
|
|
174
|
+
"id": int(time.time() * 1000),
|
|
175
|
+
}
|
|
176
|
+
body = json.dumps(payload).encode("utf-8")
|
|
177
|
+
req = request.Request(
|
|
178
|
+
self.endpoint,
|
|
179
|
+
data=body,
|
|
180
|
+
method="POST",
|
|
181
|
+
headers={
|
|
182
|
+
"Accept": "application/json, text/event-stream",
|
|
183
|
+
"Content-Type": "application/json",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with request.urlopen(req, timeout=self.rpc_timeout) as resp:
|
|
189
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
190
|
+
raw = resp.read().decode("utf-8")
|
|
191
|
+
except error.HTTPError as exc:
|
|
192
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"HTTP RPC failed: status={exc.code}, body={raw or exc.reason}"
|
|
195
|
+
) from exc
|
|
196
|
+
except error.URLError as exc:
|
|
197
|
+
raise RuntimeError(f"HTTP RPC failed: {exc.reason}") from exc
|
|
198
|
+
|
|
199
|
+
if "text/event-stream" in content_type:
|
|
200
|
+
return self._parse_sse_response(raw)
|
|
201
|
+
if not raw:
|
|
202
|
+
raise RuntimeError("HTTP RPC response was empty")
|
|
203
|
+
payload = json.loads(raw)
|
|
204
|
+
if not isinstance(payload, dict):
|
|
205
|
+
raise RuntimeError("HTTP RPC response must be a JSON object")
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
def _parse_sse_response(self, raw: str) -> Dict[str, Any]:
|
|
209
|
+
for event in raw.split("\n\n"):
|
|
210
|
+
data_lines = []
|
|
211
|
+
for line in event.splitlines():
|
|
212
|
+
if line.startswith("data:"):
|
|
213
|
+
data_lines.append(line[5:].lstrip())
|
|
214
|
+
if not data_lines:
|
|
215
|
+
continue
|
|
216
|
+
payload = json.loads("\n".join(data_lines))
|
|
217
|
+
if isinstance(payload, dict) and ("result" in payload or "error" in payload):
|
|
218
|
+
return payload
|
|
219
|
+
raise RuntimeError("SSE stream ended without a JSON-RPC response")
|
|
220
|
+
|
|
221
|
+
|
|
151
222
|
class ECPRunner:
|
|
152
223
|
"""The Orchestrator."""
|
|
153
224
|
|
|
@@ -163,7 +234,7 @@ class ECPRunner:
|
|
|
163
234
|
logger.info("Scenario: %s", scenario.name)
|
|
164
235
|
|
|
165
236
|
rpc_timeout = float(os.environ.get("ECP_RPC_TIMEOUT", "30"))
|
|
166
|
-
agent =
|
|
237
|
+
agent = self._create_agent(self.manifest.target, rpc_timeout=rpc_timeout)
|
|
167
238
|
agent.start()
|
|
168
239
|
|
|
169
240
|
try:
|
|
@@ -225,6 +296,11 @@ class ECPRunner:
|
|
|
225
296
|
"scenarios": report_data
|
|
226
297
|
}
|
|
227
298
|
|
|
299
|
+
def _create_agent(self, target: str, rpc_timeout: float):
|
|
300
|
+
if _is_http_url(target):
|
|
301
|
+
return HTTPAgentClient(target, rpc_timeout=rpc_timeout)
|
|
302
|
+
return AgentProcess(target, rpc_timeout=rpc_timeout)
|
|
303
|
+
|
|
228
304
|
def _ensure_rpc_success(
|
|
229
305
|
self,
|
|
230
306
|
rpc_resp: Dict[str, Any],
|
|
@@ -244,3 +320,8 @@ class ECPRunner:
|
|
|
244
320
|
raise RuntimeError(
|
|
245
321
|
f"RPC call failed ({method}) at {where}: code={code}, message={message}"
|
|
246
322
|
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _is_http_url(target: str) -> bool:
|
|
326
|
+
parsed = urlparse(target)
|
|
327
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import textwrap
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
12
|
+
RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExampleIntegrationTests(unittest.TestCase):
|
|
16
|
+
def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
|
|
17
|
+
env = dict(os.environ)
|
|
18
|
+
existing = env.get("PYTHONPATH", "")
|
|
19
|
+
extra_paths = [str(RUNTIME_SRC)]
|
|
20
|
+
if existing:
|
|
21
|
+
extra_paths.append(existing)
|
|
22
|
+
env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
23
|
+
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
[
|
|
26
|
+
sys.executable,
|
|
27
|
+
"-m",
|
|
28
|
+
"ecp_runtime.cli",
|
|
29
|
+
"run",
|
|
30
|
+
"--manifest",
|
|
31
|
+
str(manifest_path),
|
|
32
|
+
"--json",
|
|
33
|
+
],
|
|
34
|
+
cwd=REPO_ROOT,
|
|
35
|
+
text=True,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
env=env,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_plain_python_demo_manifest_passes(self) -> None:
|
|
42
|
+
manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
|
|
43
|
+
result = self._run_manifest(manifest)
|
|
44
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
45
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
46
|
+
|
|
47
|
+
def test_two_agent_demo_manifest_passes(self) -> None:
|
|
48
|
+
manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
|
|
49
|
+
result = self._run_manifest(manifest)
|
|
50
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
51
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
52
|
+
|
|
53
|
+
def test_streamable_http_demo_manifest_passes(self) -> None:
|
|
54
|
+
port = self._free_port()
|
|
55
|
+
server_env = dict(os.environ)
|
|
56
|
+
sdk_src = REPO_ROOT / "sdk" / "python" / "src"
|
|
57
|
+
existing = server_env.get("PYTHONPATH", "")
|
|
58
|
+
extra_paths = [str(sdk_src)]
|
|
59
|
+
if existing:
|
|
60
|
+
extra_paths.append(existing)
|
|
61
|
+
server_env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
62
|
+
server_env["ECP_HTTP_PORT"] = str(port)
|
|
63
|
+
|
|
64
|
+
server = subprocess.Popen(
|
|
65
|
+
[sys.executable, str(REPO_ROOT / "examples" / "streamable_http_demo" / "agent.py")],
|
|
66
|
+
cwd=REPO_ROOT,
|
|
67
|
+
text=True,
|
|
68
|
+
stdout=subprocess.PIPE,
|
|
69
|
+
stderr=subprocess.PIPE,
|
|
70
|
+
env=server_env,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
self._wait_for_port("127.0.0.1", port)
|
|
75
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
76
|
+
manifest_path = Path(tmpdir) / "streamable-http-manifest.yaml"
|
|
77
|
+
manifest_path.write_text(
|
|
78
|
+
textwrap.dedent(
|
|
79
|
+
f"""
|
|
80
|
+
manifest_version: "v1"
|
|
81
|
+
name: "Streamable HTTP Transport Validation"
|
|
82
|
+
target: "http://127.0.0.1:{port}/ecp"
|
|
83
|
+
|
|
84
|
+
scenarios:
|
|
85
|
+
- name: "HTTP Echo"
|
|
86
|
+
steps:
|
|
87
|
+
- input: "echo: transport is online"
|
|
88
|
+
graders:
|
|
89
|
+
- type: text_match
|
|
90
|
+
field: public_output
|
|
91
|
+
condition: contains
|
|
92
|
+
value: "transport is online"
|
|
93
|
+
- type: tool_usage
|
|
94
|
+
tool_name: "http_echo"
|
|
95
|
+
arguments:
|
|
96
|
+
message: "transport is online"
|
|
97
|
+
"""
|
|
98
|
+
).strip(),
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
)
|
|
101
|
+
result = self._run_manifest(manifest_path)
|
|
102
|
+
|
|
103
|
+
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
104
|
+
self.assertIn('"failed": 0', result.stdout)
|
|
105
|
+
finally:
|
|
106
|
+
server.terminate()
|
|
107
|
+
try:
|
|
108
|
+
server.communicate(timeout=2)
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
server.kill()
|
|
111
|
+
server.communicate()
|
|
112
|
+
|
|
113
|
+
def _free_port(self) -> int:
|
|
114
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
115
|
+
sock.bind(("127.0.0.1", 0))
|
|
116
|
+
return int(sock.getsockname()[1])
|
|
117
|
+
|
|
118
|
+
def _wait_for_port(self, host: str, port: int) -> None:
|
|
119
|
+
deadline = time.time() + 5
|
|
120
|
+
while time.time() < deadline:
|
|
121
|
+
if self._port_is_open(host, port):
|
|
122
|
+
return
|
|
123
|
+
time.sleep(0.05)
|
|
124
|
+
raise AssertionError(f"Timed out waiting for {host}:{port}")
|
|
125
|
+
|
|
126
|
+
def _port_is_open(self, host: str, port: int) -> bool:
|
|
127
|
+
try:
|
|
128
|
+
with socket.create_connection((host, port), timeout=0.2):
|
|
129
|
+
return True
|
|
130
|
+
except OSError:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
unittest.main()
|
|
@@ -9,7 +9,7 @@ if str(RUNTIME_SRC) not in sys.path:
|
|
|
9
9
|
sys.path.insert(0, str(RUNTIME_SRC))
|
|
10
10
|
|
|
11
11
|
from ecp_runtime.manifest import StepConfig
|
|
12
|
-
from ecp_runtime.runner import ECPRunner
|
|
12
|
+
from ecp_runtime.runner import ECPRunner, HTTPAgentClient
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class RunnerTests(unittest.TestCase):
|
|
@@ -68,6 +68,50 @@ class RunnerTests(unittest.TestCase):
|
|
|
68
68
|
self.assertIn("step=1", msg)
|
|
69
69
|
self.assertIn("boom", msg)
|
|
70
70
|
|
|
71
|
+
def test_runner_uses_http_client_for_url_target(self) -> None:
|
|
72
|
+
runner = ECPRunner(SimpleNamespace(target="http://127.0.0.1:8765/ecp", scenarios=[]))
|
|
73
|
+
|
|
74
|
+
agent = runner._create_agent(runner.manifest.target, rpc_timeout=12.0)
|
|
75
|
+
|
|
76
|
+
self.assertIsInstance(agent, HTTPAgentClient)
|
|
77
|
+
self.assertEqual(agent.endpoint, "http://127.0.0.1:8765/ecp")
|
|
78
|
+
self.assertEqual(agent.rpc_timeout, 12.0)
|
|
79
|
+
|
|
80
|
+
def test_http_agent_client_posts_json_rpc(self) -> None:
|
|
81
|
+
class FakeResponse:
|
|
82
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
83
|
+
|
|
84
|
+
def __enter__(self):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *_args):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def read(self):
|
|
91
|
+
return b'{"jsonrpc":"2.0","id":1,"result":{"ok":true}}'
|
|
92
|
+
|
|
93
|
+
captured = {}
|
|
94
|
+
|
|
95
|
+
def fake_urlopen(req, timeout):
|
|
96
|
+
captured["url"] = req.full_url
|
|
97
|
+
captured["timeout"] = timeout
|
|
98
|
+
captured["body"] = req.data
|
|
99
|
+
captured["accept"] = req.headers.get("Accept")
|
|
100
|
+
return FakeResponse()
|
|
101
|
+
|
|
102
|
+
with mock.patch("ecp_runtime.runner.request.urlopen", fake_urlopen):
|
|
103
|
+
response = HTTPAgentClient("http://agent.test/ecp", rpc_timeout=3).send_rpc(
|
|
104
|
+
"agent/step", {"input": "hi"}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(response["result"]["ok"], True)
|
|
108
|
+
self.assertEqual(captured["url"], "http://agent.test/ecp")
|
|
109
|
+
self.assertEqual(captured["timeout"], 3)
|
|
110
|
+
self.assertIn("application/json", captured["accept"])
|
|
111
|
+
body = captured["body"].decode("utf-8")
|
|
112
|
+
self.assertIn('"method": "agent/step"', body)
|
|
113
|
+
self.assertIn('"input": "hi"', body)
|
|
114
|
+
|
|
71
115
|
|
|
72
116
|
if __name__ == "__main__":
|
|
73
117
|
unittest.main()
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
import unittest
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
8
|
-
RUNTIME_SRC = Path(__file__).resolve().parents[1] / "src"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ExampleIntegrationTests(unittest.TestCase):
|
|
12
|
-
def _run_manifest(self, manifest_path: Path) -> subprocess.CompletedProcess[str]:
|
|
13
|
-
env = dict(os.environ)
|
|
14
|
-
existing = env.get("PYTHONPATH", "")
|
|
15
|
-
extra_paths = [str(RUNTIME_SRC)]
|
|
16
|
-
if existing:
|
|
17
|
-
extra_paths.append(existing)
|
|
18
|
-
env["PYTHONPATH"] = os.pathsep.join(extra_paths)
|
|
19
|
-
|
|
20
|
-
return subprocess.run(
|
|
21
|
-
[
|
|
22
|
-
sys.executable,
|
|
23
|
-
"-m",
|
|
24
|
-
"ecp_runtime.cli",
|
|
25
|
-
"run",
|
|
26
|
-
"--manifest",
|
|
27
|
-
str(manifest_path),
|
|
28
|
-
"--json",
|
|
29
|
-
],
|
|
30
|
-
cwd=REPO_ROOT,
|
|
31
|
-
text=True,
|
|
32
|
-
capture_output=True,
|
|
33
|
-
env=env,
|
|
34
|
-
check=False,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
def test_plain_python_demo_manifest_passes(self) -> None:
|
|
38
|
-
manifest = REPO_ROOT / "examples" / "plain_python_demo" / "manifest.yaml"
|
|
39
|
-
result = self._run_manifest(manifest)
|
|
40
|
-
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
41
|
-
self.assertIn('"failed": 0', result.stdout)
|
|
42
|
-
|
|
43
|
-
def test_two_agent_demo_manifest_passes(self) -> None:
|
|
44
|
-
manifest = REPO_ROOT / "examples" / "two_agent_demo" / "manifest.yaml"
|
|
45
|
-
result = self._run_manifest(manifest)
|
|
46
|
-
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
|
|
47
|
-
self.assertIn('"failed": 0', result.stdout)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if __name__ == "__main__":
|
|
51
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|