code-battles-cli 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: code-battles-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A CLI for competitors in any Code Battles event!
5
5
  License-File: LICENSE.md
6
6
  Requires-Python: >=3.8
@@ -77,5 +77,5 @@ print(client.run_simulation("NYC", ["bots/example.py", "bots/another_example.py"
77
77
 
78
78
  - [x] Download bots.
79
79
  - [x] Upload bots.
80
- - [ ] Run no-UI simulations locally.
80
+ - [x] Run no-UI simulations locally.
81
81
  - [x] Be usable in scripts.
@@ -65,5 +65,5 @@ print(client.run_simulation("NYC", ["bots/example.py", "bots/another_example.py"
65
65
 
66
66
  - [x] Download bots.
67
67
  - [x] Upload bots.
68
- - [ ] Run no-UI simulations locally.
68
+ - [x] Run no-UI simulations locally.
69
69
  - [x] Be usable in scripts.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "code-battles-cli"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "A CLI for competitors in any Code Battles event!"
5
5
  dependencies = [
6
6
  "requests>=2.32.3",
@@ -4,19 +4,27 @@ Code Battles Python Client API
4
4
  Firestore client implementation inspired by https://medium.com/@bobthomas295/client-side-authentication-with-python-firestore-and-firebase-352e484a2634
5
5
  """
6
6
 
7
+ import base64
8
+ import datetime
9
+ import logging
10
+ import gzip
7
11
  import os
8
12
  import json
9
13
  import shutil
10
14
  import subprocess
11
15
  import sys
12
- from typing import Dict, List, Optional, Union
16
+ from typing import Any, Callable, Dict, List, Optional, Union
13
17
  from dataclasses import dataclass
14
18
 
15
19
  import requests
16
20
  from google.oauth2.credentials import Credentials
17
21
  from google.cloud.firestore import Client as FirestoreClient
18
22
  from rich.prompt import Prompt
19
- from code_battles_cli.log import console, log
23
+ from code_battles_cli.log import console, log, progress
24
+
25
+
26
+ SIMULATION_FINISHED_MARK = b"--- SIMULATION FINISHED ---"
27
+ SIMULATION_STEP_MARK = b"__CODE_BATTLES_ADVANCE_STEP"
20
28
 
21
29
 
22
30
  @dataclass
@@ -35,6 +43,53 @@ class SimulationResults:
35
43
  logs: List[LogEntry]
36
44
 
37
45
 
46
+ @dataclass
47
+ class Simulation:
48
+ map: str
49
+ player_names: str
50
+ game: str
51
+ version: str
52
+ timestamp: datetime.datetime
53
+ logs: list
54
+ decisions: List[bytes]
55
+ seed: int
56
+
57
+ def dump(self):
58
+ return base64.b64encode(
59
+ gzip.compress(
60
+ json.dumps(
61
+ {
62
+ "map": self.map,
63
+ "playerNames": self.player_names,
64
+ "game": self.game,
65
+ "version": self.version,
66
+ "timestamp": self.timestamp.isoformat(),
67
+ "logs": self.logs,
68
+ "decisions": [
69
+ base64.b64encode(decision).decode()
70
+ for decision in self.decisions
71
+ ],
72
+ "seed": self.seed,
73
+ }
74
+ ).encode()
75
+ )
76
+ ).decode()
77
+
78
+ @staticmethod
79
+ def load(file: str):
80
+ contents: Dict[str, Any] = json.loads(gzip.decompress(base64.b64decode(file)))
81
+ return Simulation(
82
+ contents["map"],
83
+ contents["playerNames"],
84
+ contents["game"],
85
+ contents["version"],
86
+ datetime.datetime.fromisoformat(contents["timestamp"]),
87
+ contents["logs"],
88
+ [base64.b64decode(decision) for decision in contents["decisions"]],
89
+ contents["seed"],
90
+ )
91
+
92
+
38
93
  class Client:
39
94
  def __init__(
40
95
  self,
@@ -141,13 +196,89 @@ class Client:
141
196
  """
142
197
  self.document.set(bots, merge)
143
198
 
199
+ def _possibly_download(self, force_download=False):
200
+ code_directory = os.path.expanduser(
201
+ f"~/.cache/code-battles/{''.join([c for c in self.url.removeprefix("https").removesuffix(".web.app") if c.isalnum()])}"
202
+ )
203
+
204
+ if force_download and os.path.exists(code_directory):
205
+ shutil.rmtree(code_directory)
206
+
207
+ if not os.path.exists(code_directory):
208
+ os.makedirs(code_directory)
209
+
210
+ with console.status("[blue]Fetching configuration from game website..."):
211
+ pyscript_configuration = requests.get(self.url + "/config.json").json()
212
+ logging.info(
213
+ f"Fetched configuration containing {len(pyscript_configuration["files"])} game files."
214
+ )
215
+
216
+ with progress:
217
+ progress_id = progress.add_task(
218
+ "[blue]Fetching game files...",
219
+ total=len(pyscript_configuration["files"]),
220
+ )
221
+
222
+ for url_path, path in pyscript_configuration["files"].items():
223
+ if not path.endswith(".py"):
224
+ continue
225
+
226
+ local_path = os.path.join(code_directory, *path.split("/"))
227
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
228
+ text = requests.get(self.url + url_path).text
229
+ with open(local_path, "w") as f:
230
+ f.write(text)
231
+ progress.update(progress_id, advance=1)
232
+ progress.update(progress_id, visible=False)
233
+
234
+ logging.info("Finished fetching game files.")
235
+
236
+ return code_directory
237
+
238
+ def _get_simulation_output(
239
+ self,
240
+ p: subprocess.Popen,
241
+ json_output=False,
242
+ on_step: Optional[Callable[[], None]] = None,
243
+ ) -> Union[SimulationResults, str]:
244
+ while True:
245
+ line: bytes = p.stdout.readline()
246
+ line = line.strip()
247
+ if line == SIMULATION_FINISHED_MARK:
248
+ break
249
+ elif line == SIMULATION_STEP_MARK:
250
+ if on_step is not None:
251
+ on_step()
252
+ elif len(line) != 0:
253
+ logging.info(line.decode())
254
+
255
+ output: bytes = p.stdout.read()
256
+ if json_output:
257
+ return output.decode()
258
+
259
+ output = json.loads(output)
260
+ return SimulationResults(
261
+ output["winner_index"],
262
+ output["winner"],
263
+ output["steps"],
264
+ [
265
+ LogEntry(
266
+ entry["step"], entry["text"], entry["color"], entry["player_index"]
267
+ )
268
+ for entry in output["logs"]
269
+ ],
270
+ )
271
+
144
272
  def run_simulation(
145
273
  self,
146
274
  map: str,
147
275
  bot_filenames: List[str],
148
276
  bot_names: Optional[List[str]] = None,
277
+ seed: Optional[int] = None,
149
278
  force_download=False,
150
279
  json_output=False,
280
+ on_step: Optional[Callable[[], None]] = None,
281
+ output_file: Optional[str] = None,
151
282
  ) -> Union[SimulationResults, str]:
152
283
  """
153
284
  Runs the given simulation without UI locally.
@@ -164,52 +295,53 @@ class Client:
164
295
  for filename in bot_filenames
165
296
  ]
166
297
 
167
- code_directory = os.path.expanduser(
168
- f"~/.cache/code-battles/{''.join([c for c in self.url.removeprefix("https").removesuffix(".web.app") if c.isalnum()])}"
169
- )
170
-
171
- if force_download and os.path.exists(code_directory):
172
- shutil.rmtree(code_directory)
173
-
174
- if not os.path.exists(code_directory):
175
- os.makedirs(code_directory)
176
-
177
- pyscript_configuration = requests.get(self.url + "/config.json").json()
178
-
179
- for url_path, path in pyscript_configuration["files"].items():
180
- local_path = os.path.join(
181
- code_directory, *path.replace(".pyi", ".py").split("/")
182
- )
183
- os.makedirs(os.path.dirname(local_path), exist_ok=True)
184
- text = requests.get(self.url + url_path).text
185
- with open(local_path, "w") as f:
186
- f.write(text)
298
+ code_directory = self._possibly_download(force_download=force_download)
187
299
 
188
300
  p = subprocess.Popen(
189
301
  [
190
302
  sys.executable,
191
303
  os.path.join(code_directory, "main.py"),
304
+ "simulate",
305
+ str(seed),
306
+ str(output_file),
192
307
  map,
193
308
  "-".join(bot_names),
194
309
  ]
195
310
  + [os.path.abspath(f) for f in bot_filenames],
196
311
  env={"PYTHONPATH": os.path.join(code_directory, "code_battles")},
197
312
  stdout=subprocess.PIPE,
313
+ stderr=subprocess.PIPE,
198
314
  )
199
- p.wait()
200
- output = p.stdout.read().split(b"--- SIMULATION FINISHED ---")[-1]
201
- if json_output:
202
- return output.decode()
203
315
 
204
- output = json.loads(output)
205
- return SimulationResults(
206
- output["winner_index"],
207
- output["winner"],
208
- output["steps"],
316
+ return self._get_simulation_output(p, json_output, on_step)
317
+
318
+ def run_simulation_from_file(
319
+ self,
320
+ simulation_file: str,
321
+ force_download=False,
322
+ json_output=False,
323
+ on_step: Optional[Callable[[], None]] = None,
324
+ ) -> Union[SimulationResults, str]:
325
+ """
326
+ Runs the given simulation without UI locally from the given simulation file.
327
+
328
+ If required (or ``force_download``), this method downloads the simulation code from the website.
329
+
330
+ If ``json_output`` is ``True``, returns the JSON string of the results instead.
331
+ """
332
+
333
+ code_directory = self._possibly_download(force_download=force_download)
334
+
335
+ p = subprocess.Popen(
209
336
  [
210
- LogEntry(
211
- entry["step"], entry["text"], entry["color"], entry["player_index"]
212
- )
213
- for entry in output["logs"]
337
+ sys.executable,
338
+ os.path.join(code_directory, "main.py"),
339
+ "simulate-from-file",
340
+ simulation_file,
214
341
  ],
342
+ env={"PYTHONPATH": os.path.join(code_directory, "code_battles")},
343
+ stdout=subprocess.PIPE,
344
+ stderr=subprocess.PIPE,
215
345
  )
346
+
347
+ return self._get_simulation_output(p, json_output, on_step)
@@ -4,6 +4,7 @@ import click
4
4
 
5
5
 
6
6
  @click.group()
7
+ @click.version_option(message="%(prog)s %(version)s")
7
8
  def cli():
8
9
  pass
9
10
 
@@ -15,6 +15,7 @@ from code_battles_cli.log import log
15
15
  @click.option("-p", "--password", help="Your team's password")
16
16
  @click.option(
17
17
  "--dump-credentials",
18
+ is_flag=True,
18
19
  default=True,
19
20
  show_default=True,
20
21
  help="Dump the URL, username and password to a `code-battles.json` file",
@@ -0,0 +1,114 @@
1
+ import time
2
+ import logging
3
+ import sys
4
+ from typing import List, Optional, Tuple
5
+
6
+ import click
7
+
8
+ from code_battles_cli.api import Client, Simulation
9
+ from code_battles_cli.log import log, progress
10
+
11
+
12
+ @click.command()
13
+ @click.argument("map", required=False)
14
+ @click.argument("bots", nargs=-1)
15
+ @click.option(
16
+ "-U", "--url", help="The competitions URL, for example https://code-battles.web.app"
17
+ )
18
+ @click.option("-u", "--username", help="Your team's username, for example mercedes")
19
+ @click.option("-p", "--password", help="Your team's password")
20
+ @click.option(
21
+ "--dump-credentials",
22
+ is_flag=True,
23
+ default=True,
24
+ show_default=True,
25
+ help="Dump the URL, username and password to a `code-battles.json` file",
26
+ )
27
+ @click.option(
28
+ "--force-download",
29
+ is_flag=True,
30
+ default=False,
31
+ show_default=True,
32
+ help="Re-downloads the simulation code from the website",
33
+ )
34
+ @click.option(
35
+ "-s", "--seed", type=int, help="Sets the given randomness seed for the simulation"
36
+ )
37
+ @click.option(
38
+ "-S",
39
+ "--simulation-file",
40
+ type=click.Path(exists=True, dir_okay=False),
41
+ help="Re-downloads the simulation code from the website",
42
+ )
43
+ @click.option(
44
+ "-O",
45
+ "output_file",
46
+ type=click.Path(exists=False),
47
+ help="Output file to dump the simulation into",
48
+ )
49
+ def run(
50
+ url: Optional[str],
51
+ username: Optional[str],
52
+ password: Optional[str],
53
+ dump_credentials: bool,
54
+ map: str,
55
+ bots: Tuple[str],
56
+ force_download: bool,
57
+ seed: Optional[int],
58
+ simulation_file: Optional[str],
59
+ output_file: Optional[str],
60
+ ):
61
+ logging.getLogger("rich").setLevel(logging.WARNING)
62
+ client = Client(url, username, password, dump_credentials)
63
+ client._possibly_download(force_download)
64
+ start = time.time()
65
+
66
+ with progress:
67
+ progress_id = progress.add_task("[green]Simulating...", total=None)
68
+
69
+ def on_step():
70
+ progress.update(progress_id, advance=1)
71
+
72
+ if simulation_file is not None:
73
+ assert (
74
+ map is None
75
+ ), "The simulation's map is given from the simulation file!"
76
+ assert (
77
+ len(bots) == 0
78
+ ), "The simulation's bots are given from the simulation file!"
79
+ assert (
80
+ seed is None
81
+ ), "The simulation's seed is given from the simulation file!"
82
+ assert (
83
+ output_file is None
84
+ ), "Shouldn't dump simualtion file from existing simulation file!"
85
+
86
+ with open(simulation_file, "r") as f:
87
+ simulation_text = f.read()
88
+ simulation = Simulation.load(simulation_text)
89
+ logging.info(
90
+ f"Detected simulation file of {simulation.game} {simulation.version}"
91
+ )
92
+ logging.info(
93
+ f"Simulation happened in {simulation.map} between {', '.join(simulation.player_names)} with seed {simulation.seed}"
94
+ )
95
+ logging.info(f"Simulation contains {len(simulation.decisions)} decisions")
96
+ progress.update(progress_id, total=len(simulation.decisions) - 1)
97
+ results = client.run_simulation_from_file(
98
+ simulation_file, force_download=False, on_step=on_step
99
+ )
100
+ else:
101
+ assert map is not None, "Map is required for simulation!"
102
+ assert len(bots) > 0, "Bots are required for simulation!"
103
+
104
+ results = client.run_simulation(
105
+ map,
106
+ list(bots),
107
+ seed=seed,
108
+ force_download=False,
109
+ on_step=on_step,
110
+ output_file=output_file,
111
+ )
112
+ print("The winner is", results.winner, "after", results.steps, "steps.")
113
+ end = time.time()
114
+ log.info(f"Simulation took {(end - start) :.2f}s.")
@@ -15,6 +15,7 @@ from code_battles_cli.log import log
15
15
  @click.option("-p", "--password", help="Your team's password")
16
16
  @click.option(
17
17
  "--dump-credentials",
18
+ is_flag=True,
18
19
  default=True,
19
20
  show_default=True,
20
21
  help="Dump the URL, username and password to a `code-battles.json` file",
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import logging
2
3
 
3
4
  from rich.console import Console
@@ -25,7 +26,7 @@ def setup_logging():
25
26
  FORMAT = "%(message)s"
26
27
 
27
28
  logging.basicConfig(
28
- level=logging.INFO,
29
+ level=os.environ.get("CODE_BATTLES_LOG_LEVEL", "INFO"),
29
30
  format=FORMAT,
30
31
  datefmt="[%X]",
31
32
  handlers=[RichHandler(console=console, markup=True)],
@@ -1,46 +0,0 @@
1
- import time
2
- import logging
3
- from typing import List, Optional, Tuple
4
-
5
- import click
6
-
7
- from code_battles_cli.api import Client
8
- from code_battles_cli.log import log
9
-
10
-
11
- @click.command()
12
- @click.argument("map")
13
- @click.argument("bots", nargs=-1)
14
- @click.option(
15
- "-U", "--url", help="The competitions URL, for example https://code-battles.web.app"
16
- )
17
- @click.option("-u", "--username", help="Your team's username, for example mercedes")
18
- @click.option("-p", "--password", help="Your team's password")
19
- @click.option(
20
- "--dump-credentials",
21
- default=True,
22
- show_default=True,
23
- help="Dump the URL, username and password to a `code-battles.json` file",
24
- )
25
- @click.option(
26
- "--force-download",
27
- default=False,
28
- show_default=True,
29
- help="Re-downloads the simulation code from the website",
30
- )
31
- def run(
32
- url: Optional[str],
33
- username: Optional[str],
34
- password: Optional[str],
35
- dump_credentials: bool,
36
- map: str,
37
- bots: Tuple[str],
38
- force_download: bool,
39
- ):
40
- logging.getLogger("rich").setLevel(logging.WARNING)
41
- client = Client(url, username, password, dump_credentials)
42
- start = time.time()
43
- results = client.run_simulation(map, list(bots), force_download=force_download)
44
- print("The winner is", results.winner, "after", results.steps, "steps.")
45
- end = time.time()
46
- log.info(f"Simulation took {(end - start) :.2f}s.")