diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5978e67..06fdc4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,15 +66,9 @@ jobs: - name: C++ client conforms to SPEC.md run: | - uv run --with websockets examples/conformance_server.py \ - --port 8765 --steps 10 --horizon 4 --action-dim 7 --timeout 120 & - server=$! - for _ in $(seq 30); do - (echo > /dev/tcp/127.0.0.1/8765) 2>/dev/null && break - sleep 1 - done - /tmp/plugrl_client 127.0.0.1 8765 10 - wait $server + uv run --extra conformance plugrl-conformance \ + --port 8765 --steps 10 --horizon 4 --action-dim 7 --timeout 120 \ + --client "/tmp/plugrl_client 127.0.0.1 8765 10" # The action dtype is the environment's and is never renegotiated, so a # client has to read the typestr. A client that hard-codes float32 still @@ -84,16 +78,12 @@ jobs: # so the client's own output says whether it decoded them. - name: ... and reads the action typestr rather than assuming float32 run: | - uv run --with websockets examples/conformance_server.py \ + # Without pipefail a failing check would be hidden by tee's status. + set -o pipefail + uv run --extra conformance plugrl-conformance \ --port 8766 --steps 3 --horizon 2 --action-dim 3 \ - --action-dtype float64 --timeout 120 & - server=$! - for _ in $(seq 30); do - (echo > /dev/tcp/127.0.0.1/8766) 2>/dev/null && break - sleep 1 - done - /tmp/plugrl_client 127.0.0.1 8766 3 | tee /tmp/out.txt - wait $server + --action-dtype float64 --timeout 120 \ + --client "/tmp/plugrl_client 127.0.0.1 8766 3" | tee /tmp/out.txt grep -q 'dtype= /dev/tcp/127.0.0.1/8767) 2>/dev/null && break - sleep 1 - done - uv run --with websockets examples/raw_client.py \ - --host 127.0.0.1 --port 8767 --steps 10 - wait $server + uv run --extra conformance plugrl-conformance \ + --port 8767 --steps 10 --horizon 3 --action-dim 5 --timeout 120 \ + --client "python examples/raw_client.py --host 127.0.0.1 --port 8767 --steps 10" diff --git a/README.md b/README.md index fb92a71..580e022 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,25 @@ own known defects, and everything in it that can be checked by a test is. |---|---| | [`SPEC.md`](SPEC.md) | the protocol, in full | | [`src/plugrl_protocol/`](src/plugrl_protocol/) | the message types and the msgpack codec — 101 lines | +| [`src/plugrl_protocol/conformance.py`](src/plugrl_protocol/conformance.py) | `plugrl-conformance`, which grades a client clause by clause | | [`examples/`](examples/) | two env clients written against the spec, sharing no code with PlugRL | | [`tests/`](tests/) | the spec's checkable clauses, as tests | -The package is small on purpose. It is the piece both sides import, so -anything that could live on one side does. +The codec is small on purpose. It is the piece both sides import, so anything +that could live on one side does; the checker sits beside it rather than in +it, and is not imported by either side at runtime. + +## Checking a client + +One command, which starts your client once the socket is listening: + +```bash +uv run --extra conformance plugrl-conformance \ + --port 8000 --steps 20 --client "./my_client 127.0.0.1 8000 20" +``` + +It exits non-zero on a violation, so it can sit in a CI job. What it does not +require, because SPEC.md does not, is listed at the top of the module. ## The protocol in one screen @@ -87,8 +101,7 @@ pip install "plugrl-protocol @ git+https://github.com/PlugRL/plugrl-protocol.git Only Python clients that want the shared codec need this at all. A client in another language should implement [SPEC.md](SPEC.md) directly, which is what -the C++ example does - and what `examples/conformance_server.py` will grade -it against. +the C++ example does - and what `plugrl-conformance` will grade it against. ## License diff --git a/examples/conformance_server.py b/examples/conformance_server.py index 40444f7..af8dc47 100644 --- a/examples/conformance_server.py +++ b/examples/conformance_server.py @@ -1,427 +1,25 @@ -"""A server that checks an env client against SPEC.md and says what it broke. +"""The conformance checker, which now lives in the package. -`plugrl-server` is the real thing, and it is forgiving: it validates the -`message_type` and the presence of a few keys, then hands everything else to -the policy. That is the right trade for a training run and the wrong one for -someone writing a client in a new language, who wants to be told which -clause they violated rather than to watch the loss fail to move. +It moved to `plugrl_protocol.conformance` so that it can be installed and run +as `plugrl-conformance` rather than as a path into somebody's checkout. This +file stays because published records name it: E2 and E7 both quote +`python examples/conformance_server.py ...` as the command they ran, and a +record that points at a file which no longer exists is worth less than a +five-line shim. -This server speaks the same protocol and checks every clause of SPEC.md that -can be checked from one side of the wire. It prints a report and exits -non-zero if anything failed, so it can sit in a CI job. - - python conformance_server.py --port 8000 --steps 20 - ./plugrl_client 127.0.0.1 8000 20 # in another terminal - -What it deliberately does NOT require, because SPEC.md does not: - - * that the feedback's env set matches the infer's (section 4.3); - * that `n` stays the same between requests (section 5.2); - * any particular key in the observation, or any camera at all; - * the ` None: - self.checked[clause] = self.checked.get(clause, 0) + 1 - - def fail(self, clause: str, detail: str) -> None: - self.checked.setdefault(clause, 0) - self.failures.append((clause, detail)) - - def require(self, condition: bool, clause: str, detail: str) -> bool: - if condition: - self.ok(clause) - else: - self.fail(clause, detail) - return condition - - def advise(self, condition: bool, clause: str, detail: str) -> None: - """Accepted, but worth saying out loud. Never fails the run.""" - self.ok(clause) - if not condition: - self.advisories.setdefault(clause, detail) - - def render(self) -> str: - lines = ["", "=" * 68, "SPEC.md conformance report", "=" * 68] - for clause in sorted(self.checked): - broke = [d for c, d in self.failures if c == clause] - if broke: - mark = "FAIL" - elif clause in self.advisories: - mark = "note" - else: - mark = "ok " - lines.append(f" {mark} {clause} ({self.checked[clause]} checks)") - for detail in broke[:3]: - lines.append(f" {detail}") - if not broke and clause in self.advisories: - lines.append(f" {self.advisories[clause]}") - lines.append("=" * 68) - summary = ( - f"{len(self.failures)} violation(s)" if self.failures else "no violations" - ) - if self.advisories: - summary += f", {len(self.advisories)} advisory note(s)" - lines.append(summary) - return "\n".join(lines) - - -def _is_int_vector(value) -> bool: - return isinstance(value, np.ndarray) and value.ndim == 1 and value.dtype.kind == "i" - - -def _batch_sizes(observation: dict) -> set[int]: - """Leading dimensions of every array under images and states.""" - sizes = set() - for group in ("images", "states"): - for array in (observation.get(group) or {}).values(): - if isinstance(array, np.ndarray) and array.ndim >= 1: - sizes.add(int(array.shape[0])) - text = observation.get("text") - if isinstance(text, np.ndarray) and text.ndim == 1: - sizes.add(int(text.shape[0])) - elif isinstance(text, (list, tuple)): - sizes.add(len(text)) - return sizes - - -def check_observation(report: Report, observation, where: str, expect_n: int) -> None: - if not report.require( - isinstance(observation, dict), - "5.2 observation is a map", - f"{where}: got {type(observation).__name__}", - ): - return - - report.require( - set(observation) == {"images", "states", "text"}, - "5.2 observation has exactly images, states and text", - f"{where}: keys were {sorted(observation)}", - ) - - for group in ("images", "states"): - value = observation.get(group) - report.require( - isinstance(value, dict), - f"5.2 {group} is a map of named arrays", - f"{where}: {group} was {type(value).__name__}", - ) - - for name, image in (observation.get("images") or {}).items(): - report.require( - isinstance(image, np.ndarray) and image.dtype == np.uint8, - "5.2 images are uint8", - f"{where}: images[{name!r}] dtype " - f"{getattr(image, 'dtype', type(image).__name__)}", - ) - report.require( - isinstance(image, np.ndarray) and image.ndim == 4, - "5.2 images are [n, h, w, c]", - f"{where}: images[{name!r}] shape {getattr(image, 'shape', None)}", - ) - - # Section 3.4 Gap: the two shipped clients disagree here and the server - # accepts both, so this is reported, never failed. - text = observation.get("text") - report.advise( - isinstance(text, np.ndarray) and text.dtype.kind == "U", - "3.4 text is a numpy None: - report = self.report - packer = msgpack_numpy.Packer() - - # Section 5.1: the server speaks first, with an envelope, and the - # descriptive keys are how a client learns the action shape without - # being told out of band. They are sent here so that a client which - # reads them is exercised, and one which ignores them is proved not - # to break - section 5.1 requires no key to be present. - await websocket.send( - packer.pack( - { - "message_type": str(MessageType.METADATA), - "data": { - "protocol_version": 1, - "server": "conformance_server.py", - "action_horizon": self.horizon, - "action_dim": self.action_dim, - }, - } - ) - ) - - awaiting = "infer" - try: - while self.exchanges < self.steps: - payload = msgpack_numpy.unpackb(await websocket.recv()) - - if not isinstance(payload, dict): - report.fail("2 message is a map", f"got {type(payload).__name__}") - return - report.ok("2 message is a map") - - kind = payload.get("message_type") - report.require( - isinstance(kind, str), - "2 message_type is a string, not bytes", - f"got {type(kind).__name__}", - ) - if not report.require( - kind == awaiting, - "4.2 infer and feedback strictly alternate", - f"expected {awaiting!r}, received {kind!r}", - ): - return - - if kind == str(MessageType.INFER): - n = self.check_infer(payload) - if n is None: - return - await websocket.send(packer.pack(self.action_for(payload, n))) - awaiting = str(MessageType.FEEDBACK) - else: - self.check_feedback(payload) - awaiting = str(MessageType.INFER) - self.exchanges += 1 - except ws_exceptions.ConnectionClosedOK: - # The client said goodbye and left. SPEC.md section 7.5 allows - # that at any point, so it is not a failure - a client that has - # collected the episodes it wanted is finished, and the server - # does not get to call that a protocol violation. - report.ok("7.5 a client that stops sends a close frame") - except ws_exceptions.ConnectionClosedError: - # It vanished without a close frame. Section 7.5 asks for one, - # and nothing breaks without it, so this is a note. - report.advise( - False, - "7.5 a client that stops sends a close frame", - "the connection dropped with no close frame; RFC 6455 asks " - "for one and SPEC.md section 7.5 repeats the ask", - ) - except Exception as exc: # noqa: BLE001 - the report is the output - report.fail("connection", f"{type(exc).__name__}: {exc}") - finally: - self.finished.set() - - def check_infer(self, payload: dict) -> int | None: - report = self.report - for key in ("data", "env_indices", "step_ids"): - if not report.require( - key in payload, - "5.2 infer carries data, env_indices and step_ids", - f"missing {key!r}", - ): - return None - - env_indices = payload["env_indices"] - if not report.require( - _is_int_vector(env_indices), - "5.2 env_indices is a one-dimensional integer array", - f"got {getattr(env_indices, 'dtype', type(env_indices).__name__)} " - f"shape {getattr(env_indices, 'shape', None)}", - ): - return None - - n = int(env_indices.shape[0]) - report.require(n > 0, "5.2 an infer names at least one env", "n was 0") - report.require( - len(set(env_indices.tolist())) == n, - "4.4 env indices are unique within a message", - f"repeated indices in {env_indices.tolist()}", - ) - report.require( - _is_int_vector(payload["step_ids"]) and payload["step_ids"].shape[0] == n, - "5.2 step_ids matches env_indices in length", - f"step_ids {getattr(payload['step_ids'], 'shape', None)} vs n={n}", - ) - check_observation(report, payload["data"], "infer", n) - return n - - def action_for(self, payload: dict, n: int) -> dict: - """Time-major, per section 5.3, with env_ids echoed in order.""" - action = np.zeros((self.horizon, n, self.action_dim), self.action_dtype) - for t in range(self.horizon): - action[t, :, 0] = t - return { - "message_type": str(MessageType.ACTION), - "data": { - "env_ids": np.asarray(payload["env_indices"], dtype=np.int64), - "action": action, - }, - } - - def check_feedback(self, payload: dict) -> None: - report = self.report - for key in ("data", "env_indices", "step_ids"): - if not report.require( - key in payload, - "5.4 feedback carries data, env_indices and step_ids", - f"missing {key!r}", - ): - return - - env_indices = payload["env_indices"] - if not report.require( - _is_int_vector(env_indices), - "5.4 feedback env_indices is a one-dimensional integer array", - f"got {type(env_indices).__name__}", - ): - return - m = int(env_indices.shape[0]) - - data = payload["data"] - if not report.require( - isinstance(data, dict), "5.4 feedback data is a map", f"{type(data)}" - ): - return - report.require( - set(data) == {"obs", "rewards", "terminated", "truncated", "info"}, - "5.4 feedback data has exactly its five keys", - f"keys were {sorted(data)}", - ) - - rewards = data.get("rewards") - if report.require( - isinstance(rewards, np.ndarray) - and rewards.dtype.kind == "f" - and rewards.shape == (m,), - "5.4 rewards is a float array of length m", - f"got {getattr(rewards, 'dtype', type(rewards).__name__)} " - f"shape {getattr(rewards, 'shape', None)}, m={m}", - ): - # The server never inspects the width, so a wider reward is - # accepted rather than rejected - but it is not what the Python - # client sends, and a policy that reshapes by itemsize would - # notice. - report.advise( - rewards.dtype == np.float32, - "5.4 rewards is float32, as plugrl-env-client sends", - f"got {rewards.dtype}; accepted, but float32 is the norm", - ) - - for flag in ("terminated", "truncated"): - value = data.get(flag) - report.require( - isinstance(value, np.ndarray) - and value.dtype == np.bool_ - and value.shape == (m,), - f"5.4 {flag} is a bool array of length m", - f"got {getattr(value, 'dtype', type(value).__name__)} " - f"shape {getattr(value, 'shape', None)}, m={m}", - ) - - report.require( - isinstance(data.get("info"), dict), - "5.4 info is a map", - f"got {type(data.get('info')).__name__}", - ) - check_observation(report, data.get("obs"), "feedback", m) - - -async def serve(args: argparse.Namespace) -> int: - server = ConformanceServer( - horizon=args.horizon, - action_dim=args.action_dim, - action_dtype=args.action_dtype, - steps=args.steps, - ) - async with ws_server.serve( - server.handle, args.host, args.port, compression=None, max_size=None - ): - print( - f"conformance server on ws://{args.host}:{args.port} - " - f"expecting {args.steps} exchanges, horizon {args.horizon}, " - f"action dtype {args.action_dtype}", - flush=True, - ) - try: - await asyncio.wait_for(server.finished.wait(), timeout=args.timeout) - except asyncio.TimeoutError: - server.report.fail("connection", f"no client finished in {args.timeout}s") - - print(f"\ncompleted {server.exchanges} infer/action/feedback exchanges") - print(server.report.render()) - return 1 if server.report.failures else 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8000) - parser.add_argument("--steps", type=int, default=20) - parser.add_argument("--horizon", type=int, default=4) - parser.add_argument("--action-dim", type=int, default=7) - parser.add_argument( - "--action-dtype", - default="float32", - help="the environment's action dtype; a client must read the typestr", - ) - parser.add_argument("--timeout", type=float, default=120.0) - return asyncio.run(serve(parser.parse_args())) +from plugrl_protocol.conformance import main # noqa: E402 if __name__ == "__main__": raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 2f4ecec..eb3b7df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,17 @@ dependencies = [ "msgpack (>=1.1.1,<2.0.0)", ] +[project.optional-dependencies] +# The conformance checker is a server, so it needs a WebSocket library that +# the protocol package itself does not. It was an undeclared import until +# now, which is why the documentation told people to pass `--with websockets`. +conformance = [ + "websockets (>=15.0.1,<16.0.0)", +] + +[project.scripts] +plugrl-conformance = "plugrl_protocol.conformance:main" + [dependency-groups] dev = [ "pre-commit>=4.5.1,<5.0.0", diff --git a/src/plugrl_protocol/conformance.py b/src/plugrl_protocol/conformance.py new file mode 100644 index 0000000..4a7cf15 --- /dev/null +++ b/src/plugrl_protocol/conformance.py @@ -0,0 +1,473 @@ +"""A server that checks an env client against SPEC.md and says what it broke. + +`plugrl-server` is the real thing, and it is forgiving: it validates the +`message_type` and the presence of a few keys, then hands everything else to +the policy. That is the right trade for a training run and the wrong one for +someone writing a client in a new language, who wants to be told which +clause they violated rather than to watch the loss fail to move. + +This server speaks the same protocol and checks every clause of SPEC.md that +can be checked from one side of the wire. It prints a report and exits +non-zero if anything failed, so it can sit in a CI job. + +One command, which starts your client once the server is listening and waits +for both: + + plugrl-conformance --steps 20 --client "./plugrl_client 127.0.0.1 8000 20" + +Or the two halves separately, which is what `examples/conformance_server.py` +has always done: + + plugrl-conformance --port 8000 --steps 20 & + ./plugrl_client 127.0.0.1 8000 20 + +The first form exists because the second has a race in it: a client started +before the server is listening fails for a reason that has nothing to do with +the specification. + +What it deliberately does NOT require, because SPEC.md does not: + + * that the feedback's env set matches the infer's (section 4.3); + * that `n` stays the same between requests (section 5.2); + * any particular key in the observation, or any camera at all; + * the ` None: + self.checked[clause] = self.checked.get(clause, 0) + 1 + + def fail(self, clause: str, detail: str) -> None: + self.checked.setdefault(clause, 0) + self.failures.append((clause, detail)) + + def require(self, condition: bool, clause: str, detail: str) -> bool: + if condition: + self.ok(clause) + else: + self.fail(clause, detail) + return condition + + def advise(self, condition: bool, clause: str, detail: str) -> None: + """Accepted, but worth saying out loud. Never fails the run.""" + self.ok(clause) + if not condition: + self.advisories.setdefault(clause, detail) + + def render(self) -> str: + lines = ["", "=" * 68, "SPEC.md conformance report", "=" * 68] + for clause in sorted(self.checked): + broke = [d for c, d in self.failures if c == clause] + if broke: + mark = "FAIL" + elif clause in self.advisories: + mark = "note" + else: + mark = "ok " + lines.append(f" {mark} {clause} ({self.checked[clause]} checks)") + for detail in broke[:3]: + lines.append(f" {detail}") + if not broke and clause in self.advisories: + lines.append(f" {self.advisories[clause]}") + lines.append("=" * 68) + summary = ( + f"{len(self.failures)} violation(s)" if self.failures else "no violations" + ) + if self.advisories: + summary += f", {len(self.advisories)} advisory note(s)" + lines.append(summary) + return "\n".join(lines) + + +def _is_int_vector(value) -> bool: + return isinstance(value, np.ndarray) and value.ndim == 1 and value.dtype.kind == "i" + + +def _batch_sizes(observation: dict) -> set[int]: + """Leading dimensions of every array under images and states.""" + sizes = set() + for group in ("images", "states"): + for array in (observation.get(group) or {}).values(): + if isinstance(array, np.ndarray) and array.ndim >= 1: + sizes.add(int(array.shape[0])) + text = observation.get("text") + if isinstance(text, np.ndarray) and text.ndim == 1: + sizes.add(int(text.shape[0])) + elif isinstance(text, (list, tuple)): + sizes.add(len(text)) + return sizes + + +def check_observation(report: Report, observation, where: str, expect_n: int) -> None: + if not report.require( + isinstance(observation, dict), + "5.2 observation is a map", + f"{where}: got {type(observation).__name__}", + ): + return + + report.require( + set(observation) == {"images", "states", "text"}, + "5.2 observation has exactly images, states and text", + f"{where}: keys were {sorted(observation)}", + ) + + for group in ("images", "states"): + value = observation.get(group) + report.require( + isinstance(value, dict), + f"5.2 {group} is a map of named arrays", + f"{where}: {group} was {type(value).__name__}", + ) + + for name, image in (observation.get("images") or {}).items(): + report.require( + isinstance(image, np.ndarray) and image.dtype == np.uint8, + "5.2 images are uint8", + f"{where}: images[{name!r}] dtype " + f"{getattr(image, 'dtype', type(image).__name__)}", + ) + report.require( + isinstance(image, np.ndarray) and image.ndim == 4, + "5.2 images are [n, h, w, c]", + f"{where}: images[{name!r}] shape {getattr(image, 'shape', None)}", + ) + + # Section 3.4 Gap: the two shipped clients disagree here and the server + # accepts both, so this is reported, never failed. + text = observation.get("text") + report.advise( + isinstance(text, np.ndarray) and text.dtype.kind == "U", + "3.4 text is a numpy None: + report = self.report + packer = msgpack_numpy.Packer() + + # Section 5.1: the server speaks first, with an envelope, and the + # descriptive keys are how a client learns the action shape without + # being told out of band. They are sent here so that a client which + # reads them is exercised, and one which ignores them is proved not + # to break - section 5.1 requires no key to be present. + await websocket.send( + packer.pack( + { + "message_type": str(MessageType.METADATA), + "data": { + "protocol_version": 1, + "server": "conformance_server.py", + "action_horizon": self.horizon, + "action_dim": self.action_dim, + }, + } + ) + ) + + awaiting = "infer" + try: + while self.exchanges < self.steps: + payload = msgpack_numpy.unpackb(await websocket.recv()) + + if not isinstance(payload, dict): + report.fail("2 message is a map", f"got {type(payload).__name__}") + return + report.ok("2 message is a map") + + kind = payload.get("message_type") + report.require( + isinstance(kind, str), + "2 message_type is a string, not bytes", + f"got {type(kind).__name__}", + ) + if not report.require( + kind == awaiting, + "4.2 infer and feedback strictly alternate", + f"expected {awaiting!r}, received {kind!r}", + ): + return + + if kind == str(MessageType.INFER): + n = self.check_infer(payload) + if n is None: + return + await websocket.send(packer.pack(self.action_for(payload, n))) + awaiting = str(MessageType.FEEDBACK) + else: + self.check_feedback(payload) + awaiting = str(MessageType.INFER) + self.exchanges += 1 + except ws_exceptions.ConnectionClosedOK: + # The client said goodbye and left. SPEC.md section 7.5 allows + # that at any point, so it is not a failure - a client that has + # collected the episodes it wanted is finished, and the server + # does not get to call that a protocol violation. + report.ok("7.5 a client that stops sends a close frame") + except ws_exceptions.ConnectionClosedError: + # It vanished without a close frame. Section 7.5 asks for one, + # and nothing breaks without it, so this is a note. + report.advise( + False, + "7.5 a client that stops sends a close frame", + "the connection dropped with no close frame; RFC 6455 asks " + "for one and SPEC.md section 7.5 repeats the ask", + ) + except Exception as exc: # noqa: BLE001 - the report is the output + report.fail("connection", f"{type(exc).__name__}: {exc}") + finally: + self.finished.set() + + def check_infer(self, payload: dict) -> int | None: + report = self.report + for key in ("data", "env_indices", "step_ids"): + if not report.require( + key in payload, + "5.2 infer carries data, env_indices and step_ids", + f"missing {key!r}", + ): + return None + + env_indices = payload["env_indices"] + if not report.require( + _is_int_vector(env_indices), + "5.2 env_indices is a one-dimensional integer array", + f"got {getattr(env_indices, 'dtype', type(env_indices).__name__)} " + f"shape {getattr(env_indices, 'shape', None)}", + ): + return None + + n = int(env_indices.shape[0]) + report.require(n > 0, "5.2 an infer names at least one env", "n was 0") + report.require( + len(set(env_indices.tolist())) == n, + "4.4 env indices are unique within a message", + f"repeated indices in {env_indices.tolist()}", + ) + report.require( + _is_int_vector(payload["step_ids"]) and payload["step_ids"].shape[0] == n, + "5.2 step_ids matches env_indices in length", + f"step_ids {getattr(payload['step_ids'], 'shape', None)} vs n={n}", + ) + check_observation(report, payload["data"], "infer", n) + return n + + def action_for(self, payload: dict, n: int) -> dict: + """Time-major, per section 5.3, with env_ids echoed in order.""" + action = np.zeros((self.horizon, n, self.action_dim), self.action_dtype) + for t in range(self.horizon): + action[t, :, 0] = t + return { + "message_type": str(MessageType.ACTION), + "data": { + "env_ids": np.asarray(payload["env_indices"], dtype=np.int64), + "action": action, + }, + } + + def check_feedback(self, payload: dict) -> None: + report = self.report + for key in ("data", "env_indices", "step_ids"): + if not report.require( + key in payload, + "5.4 feedback carries data, env_indices and step_ids", + f"missing {key!r}", + ): + return + + env_indices = payload["env_indices"] + if not report.require( + _is_int_vector(env_indices), + "5.4 feedback env_indices is a one-dimensional integer array", + f"got {type(env_indices).__name__}", + ): + return + m = int(env_indices.shape[0]) + + data = payload["data"] + if not report.require( + isinstance(data, dict), "5.4 feedback data is a map", f"{type(data)}" + ): + return + report.require( + set(data) == {"obs", "rewards", "terminated", "truncated", "info"}, + "5.4 feedback data has exactly its five keys", + f"keys were {sorted(data)}", + ) + + rewards = data.get("rewards") + if report.require( + isinstance(rewards, np.ndarray) + and rewards.dtype.kind == "f" + and rewards.shape == (m,), + "5.4 rewards is a float array of length m", + f"got {getattr(rewards, 'dtype', type(rewards).__name__)} " + f"shape {getattr(rewards, 'shape', None)}, m={m}", + ): + # The server never inspects the width, so a wider reward is + # accepted rather than rejected - but it is not what the Python + # client sends, and a policy that reshapes by itemsize would + # notice. + report.advise( + rewards.dtype == np.float32, + "5.4 rewards is float32, as plugrl-env-client sends", + f"got {rewards.dtype}; accepted, but float32 is the norm", + ) + + for flag in ("terminated", "truncated"): + value = data.get(flag) + report.require( + isinstance(value, np.ndarray) + and value.dtype == np.bool_ + and value.shape == (m,), + f"5.4 {flag} is a bool array of length m", + f"got {getattr(value, 'dtype', type(value).__name__)} " + f"shape {getattr(value, 'shape', None)}, m={m}", + ) + + report.require( + isinstance(data.get("info"), dict), + "5.4 info is a map", + f"got {type(data.get('info')).__name__}", + ) + check_observation(report, data.get("obs"), "feedback", m) + + +async def serve(args: argparse.Namespace) -> int: + server = ConformanceServer( + horizon=args.horizon, + action_dim=args.action_dim, + action_dtype=args.action_dtype, + steps=args.steps, + ) + async with ws_server.serve( + server.handle, args.host, args.port, compression=None, max_size=None + ): + print( + f"conformance server on ws://{args.host}:{args.port} - " + f"expecting {args.steps} exchanges, horizon {args.horizon}, " + f"action dtype {args.action_dtype}", + flush=True, + ) + + # Started here rather than by the caller, so it cannot start before + # the socket is listening. A client that fails to connect fails for a + # reason that has nothing to do with the specification. + client = None + if args.client: + print(f"starting client: {args.client}", flush=True) + client = await asyncio.create_subprocess_shell(args.client) + + try: + await asyncio.wait_for(server.finished.wait(), timeout=args.timeout) + except asyncio.TimeoutError: + server.report.fail("connection", f"no client finished in {args.timeout}s") + + if client is not None: + try: + code = await asyncio.wait_for(client.wait(), timeout=30) + except asyncio.TimeoutError: + client.kill() + await client.wait() + server.report.fail( + "client process", "still running 30s after the exchanges ended" + ) + else: + # A client that crashes on the way out has not passed, however + # clean the exchanges looked. + if code != 0: + server.report.fail("client process", f"exited with status {code}") + + print(f"\ncompleted {server.exchanges} infer/action/feedback exchanges") + print(server.report.render()) + return 1 if server.report.failures else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--steps", type=int, default=20) + parser.add_argument("--horizon", type=int, default=4) + parser.add_argument("--action-dim", type=int, default=7) + parser.add_argument( + "--action-dtype", + default="float32", + help="the environment's action dtype; a client must read the typestr", + ) + parser.add_argument("--timeout", type=float, default=120.0) + parser.add_argument( + "--client", + default=None, + metavar="COMMAND", + help=( + "shell command that runs the client under test. Started once the " + "server is listening and waited for afterwards, so one command " + "does the whole check. Without it the server waits for a client " + "started elsewhere, as it always has." + ), + ) + return asyncio.run(serve(parser.parse_args())) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 90a5bdc..37048ec 100644 --- a/uv.lock +++ b/uv.lock @@ -328,6 +328,11 @@ dependencies = [ { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +[package.optional-dependencies] +conformance = [ + { name = "websockets" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, @@ -338,7 +343,9 @@ dev = [ requires-dist = [ { name = "msgpack", specifier = ">=1.1.1,<2.0.0" }, { name = "numpy", specifier = "<2.3.3" }, + { name = "websockets", marker = "extra == 'conformance'", specifier = ">=15.0.1,<16.0.0" }, ] +provides-extras = ["conformance"] [package.metadata.requires-dev] dev = [ @@ -543,3 +550,62 @@ sdist = { url = "https://files.pythonhosted.org/packages/52/5c/ba82fdd0da13ade01 wheels = [ { url = "https://files.pythonhosted.org/packages/85/fc/888c80b8da917bfc48a41887eca244f995d190bd3c01175727089fcaf27e/virtualenv-21.7.9-py3-none-any.whl", hash = "sha256:ba3b0bb41063c848d84d76a9fe3fb7711aaa0f2fe78708e8f3cd9714770e4eac", size = 5324667, upload-time = "2026-09-09T01:03:26.023Z" }, ] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +]