You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

183 lines
5.8 KiB

#!/usr/bin/env python3
import argparse
import json
import logging
import requests
import shutil
import sys
import tempfile
import time
from subprocess import Popen, PIPE, TimeoutExpired
from typing import List, Optional, Tuple
parser = argparse.ArgumentParser()
parser.add_argument("--token", type=str, required=True, help="API token.")
parser.add_argument("--program", type=str, required=True, help="Program to run.")
parser.add_argument("--server", type=str, default="http://localhost:5000", help="Server address.")
parser.add_argument("--game", type=str, default="main", help="'main' or 'test_#'.")
parser.add_argument("--copy", default=False, action="store_true", help="Save a copy of the program to ensure the same code is used all the time.")
parser.add_argument("--log-level", type=str, default="warning", choices=["error", "warning", "info"])
parser.add_argument("--save-state", type=str, default=None, help="Save state to this file.")
# parser.add_argument("program", nargs=argparse.REMAINDER, help="Program to run.")
TIME_BEFORE_RETRY = 2.0
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("client")
def main(args: argparse.Namespace) -> None:
logger.setLevel(args.log_level.upper())
program = args.program
if args.copy:
program = tempfile.mktemp()
shutil.copyfile(args.program, program)
program = "./" + program
min_round = 0
while True:
state = get_state(min_round, args)
round = state["round"]
min_round = round + 1
# if requested, dump state to file instead
if args.save_state is not None:
with open(args.save_state, "w") as f:
json.dump(state, f)
return
turn_json = run_subprocess(
program, json.dumps(state), state["time_to_response"]
)
if turn_json is None:
continue
try:
turn = json.loads(turn_json)
except json.JSONDecodeError:
logger.error(f"Invalid JSON returned by strategy code.")
sys.exit(1)
send_turn(turn, round, args)
def run_subprocess(
program: str, state_json: str, timeout: Optional[float]
) -> Optional[str]:
"""Run user program and return its output."""
proc = Popen([program], encoding="utf-8",
stdin=PIPE, stdout=PIPE, stderr=PIPE)
try:
stdout, stderr = proc.communicate(input=state_json, timeout=timeout)
except TimeoutExpired:
proc.kill()
logger.error("Strategy code took too long.")
return None
if stderr:
logger.error(f"Strategy code stderr:\n{stderr}")
if proc.returncode != 0:
logger.error("Strategy code exited with non-zero exit code.")
sys.exit(1)
return stdout
def get_state(min_round: int, args) -> dict:
"""Iteratively try to get game data from the server."""
state, wait_time = get_state_once()
while state is None:
time.sleep(wait_time)
state, wait_time = get_state_once()
def get_state_once() -> Tuple[Optional[dict], float]:
try:
res = requests.get(f"{args.server}/api/state", params={
"game": args.game,
"token": args.token,
"min_round": min_round
})
except requests.exceptions.RequestException as e:
# server might be down, retry later
logger.warning(f"Request error: {e}")
return None, TIME_BEFORE_RETRY
if not res.ok:
logger.error(f"Server error: {res.status_code} {res.reason}")
log_server_error(res)
sys.exit(1)
state = res.json()
if state["status"] == "ok":
return state, 0
# retry after some time
if state["status"] == "waiting":
logger.info("Server is busy.")
if state["status"] == "too_early":
logger.info("Round didn't start yet.")
return None, state["wait"]
logger.info("Received new state.")
return state, 0
def send_turn(turn: dict, round: int, args) -> None:
"""Iteratively try to send turn to the server."""
while not send_turn_once():
time.sleep(TIME_BEFORE_RETRY)
def send_turn_once() -> bool:
"""Returns True if server answered."""
try:
res = requests.post(
f"{args.server}/api/action",
params={
"game": args.game,
"token": args.token,
"round": round
},
json=turn
)
except requests.exceptions.RequestException as e:
# server might be down, retry later
logger.warning(f"Request error: {e}")
return False
if not res.ok:
log_server_error(res)
sys.exit(1)
# print errors
# because such errors are caused by the submitted turn,
# retrying will not help, so return True
response = res.json()
if response["status"] == "ok":
logger.info("Turn accepted.")
elif response["status"] == "too_late":
logger.error("Turn submitted too late.")
elif response["status"] == "error":
logger.error(f"Turn error: {response['description']}")
elif response["status"] == "warning":
member_warns = [
f" {member['id']}: {member['description']}"
for member in response["members"]
]
logger.warning(
"Member warnings:\n"
"\n".join(member_warns)
)
return True
def log_server_error(res: requests.Response) -> None:
logger.error(
f"Server error: {res.status_code} {res.reason}\n"
f"{res['description']}"
)
if __name__ == "__main__":
args = parser.parse_args()
main(args)