vimgolf_gym.vimgolf

Vimgolf API wrapper

  1"""
  2Vimgolf API wrapper
  3"""
  4
  5# TODO: convert vimgolf keys to terminal input string, and convert terminal input string to vimgolf keys
  6# TODO: figure out if the vim -w recorded things are the same as terminal input string
  7
  8# TODO: figure out the action space of all three input representations
  9
 10import argparse
 11import json
 12import logging
 13import typing
 14from vimgolf_gym._vimrc import (
 15    _CYBERGOD_VIMGOLF_VIMRC_FILEPATH,
 16    _prepare_cybergod_vimrc_with_buffer_file,
 17    _VIMGOLF_VIMRC_FILEPATH,
 18)
 19
 20from vimgolf.vimgolf import (
 21    IGNORED_KEYSTROKES,
 22    Challenge,
 23    Result,
 24    Status,
 25    VimRunner,
 26    filecmp,
 27    format_,
 28    get_challenge_url,
 29    get_keycode_repr,
 30    input_loop,
 31    logger,
 32    os,
 33    parse_keycodes,
 34    sys,
 35    tempfile,
 36    tokenize_keycode_reprs,
 37    upload_result,
 38    working_directory,
 39    write,
 40)
 41
 42
 43def parse_commandline_arguments():
 44    """
 45    Parse the command line arguments using argparse.
 46
 47    Args:
 48        None
 49
 50    Returns:
 51        A Namespace object with the parsed arguments.
 52
 53    The parsed arguments are:
 54        - --input_file: str, the path to the input file
 55        - --output_file: str, the path to the output file
 56        - --log_file: str, the path to the log file
 57        - --buffer_file: str, the path to the buffer file
 58        - --init_keys: str, the initial keys to type into Vim
 59    """
 60
 61    parser = argparse.ArgumentParser()
 62    parser.add_argument("--input_file", type=str, required=True)
 63    parser.add_argument("--output_file", type=str, required=True)
 64    parser.add_argument("--log_file", type=str, required=True)
 65    parser.add_argument("--buffer_file", type=str, default=None)
 66    parser.add_argument("--init_keys", type=str, default="")
 67    args = parser.parse_args()
 68    return args
 69
 70
 71def main():
 72    """
 73    Parse command line arguments and execute the vimgolf local challenge.
 74
 75    It sets up the logging to a file and then calls the local function to execute
 76    the challenge.
 77
 78    The local function is called with the input file, output file, buffer file and init keys.
 79
 80    """
 81    args = parse_commandline_arguments()
 82    log_file = args.log_file
 83    file_handler = logging.FileHandler(log_file)
 84    file_handler.setLevel(logging.DEBUG)
 85    logger.addHandler(file_handler)
 86    if args.buffer_file:
 87        local(
 88            infile=args.input_file,
 89            outfile=args.output_file,
 90            buffer_file=args.buffer_file,
 91            init_keys=args.init_keys,
 92        )
 93    else:
 94        local(
 95            infile=args.input_file,
 96            outfile=args.output_file,
 97            init_keys=args.init_keys,
 98        )
 99
100
101def local(
102    infile: str, outfile: str, buffer_file: typing.Optional[str] = None, init_keys=""
103):
104    """
105    Execute a local VimGolf challenge.
106
107    It reads the input file, output file, and runs the challenge. The challenge
108    is defined by the input and output text and the file extensions.
109
110    Args:
111        infile: str, the path to the input file
112        outfile: str, the path to the output file
113        init_keys: str, the initial keys to type into Vim
114        buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
115
116    Returns:
117        Status: The status of the challenge
118    """
119    logger.info("local(%s, %s)", infile, outfile)
120    with open(infile, "r") as f:
121        in_text = format_(f.read())
122    with open(outfile, "r") as f:
123        out_text = format_(f.read())
124    _, in_extension = os.path.splitext(infile)
125    _, out_extension = os.path.splitext(outfile)
126    challenge = Challenge(
127        in_text=in_text,
128        out_text=out_text,
129        in_extension=in_extension,
130        out_extension=out_extension,
131        id=None,
132        compliant=None,
133        api_key=None,
134        init_keys=init_keys,
135    )
136    status = play(challenge, buffer_file=buffer_file)
137    return status
138
139
140def play(challenge, results=None, buffer_file: typing.Optional[str] = None):
141    """
142    Execute a VimGolf challenge.
143
144    Args:
145        challenge: Challenge, the challenge to play
146        results: list of Result, the results of previous plays
147        buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
148
149    Returns:
150        Status: The status of the challenge
151    """
152    if results is None:
153        results = []
154    logger.info("play(...)")
155    with tempfile.TemporaryDirectory() as workspace, working_directory(workspace):
156        infile = "in"
157        if challenge.in_extension:
158            infile += challenge.in_extension
159        outfile = "out"
160        if challenge.out_extension:
161            outfile += challenge.out_extension
162        logfile = "log"
163        with open(outfile, "w") as f:
164            f.write(challenge.out_text)
165
166        try:
167            # If there were init keys specified, we need to convert them to a
168            # form suitable for feedkeys(). We can't use Vim's -s option since
169            # it takes escape codes, not key codes. See Vim #4041 and TODO.txt
170            # ("Bug: script written with "-W scriptout" contains Key codes,
171            # while the script read with "-s scriptin" expects escape codes").
172            # The conversion is conducted here so that we can fail fast on
173            # error (prior to playing) and to avoid repeated computation.
174            keycode_reprs = tokenize_keycode_reprs(challenge.init_keys)
175            init_feedkeys = []
176            for item in keycode_reprs:
177                if item == "\\":
178                    item = "\\\\"  # Replace '\' with '\\'
179                elif item == '"':
180                    item = '\\"'  # Replace '"' with '\"'
181                elif item.startswith("<") and item.endswith(">"):
182                    item = "\\" + item  # Escape special keys ("<left>" -> "\<left>")
183                init_feedkeys.append(item)
184            init_feedkeys = "".join(init_feedkeys)
185        except Exception:
186            logger.exception("invalid init keys")
187            write("Invalid keys: {}".format(challenge.init_keys), color="red")
188            return Status.FAILURE
189
190        write("Launching vimgolf session", color="yellow")
191        while True:
192            with open(infile, "w") as f:
193                f.write(challenge.in_text)
194            with open(outfile, "w") as f:
195                f.write(challenge.out_text)
196            if buffer_file:
197                _prepare_cybergod_vimrc_with_buffer_file(buffer_file)
198                vimrc = _CYBERGOD_VIMGOLF_VIMRC_FILEPATH
199            else:
200                vimrc = _VIMGOLF_VIMRC_FILEPATH
201            play_args = [
202                "-Z",  # restricted mode, utilities not allowed
203                "-n",  # no swap file, memory only editing
204                "--noplugin",  # no plugins
205                "-i",
206                "NONE",  # don't load .viminfo (e.g., has saved macros, etc.)
207                "+0",  # start on line 0
208                "-u",
209                vimrc,  # vimgolf .vimrc
210                "-U",
211                "NONE",  # don't load .gvimrc
212                "-W",
213                logfile,  # keylog file (overwrites existing)
214                '+call feedkeys("{}", "t")'.format(init_feedkeys),  # initial keys
215                infile,
216            ]
217            if VimRunner.run(play_args) != Status.SUCCESS:
218                return Status.FAILURE
219
220            correct = filecmp.cmp(infile, outfile, shallow=False)
221            logger.info("correct: %s", str(correct).lower())
222            with open(logfile, "rb") as _f:
223                # raw keypress representation saved by vim's -w
224                raw_keys = _f.read()
225
226            # list of parsed keycode byte strings
227            keycodes = parse_keycodes(raw_keys)
228            keycodes = [
229                keycode for keycode in keycodes if keycode not in IGNORED_KEYSTROKES
230            ]
231
232            # list of human-readable key strings
233            keycode_reprs = [get_keycode_repr(keycode) for keycode in keycodes]
234            logger.info("keys: %s", "".join(keycode_reprs))
235
236            score = len(keycodes)
237            logger.info("score: %d", score)
238
239            result = Result(correct=correct, keys="".join(keycode_reprs), score=score)
240            logger.info(
241                json.dumps(
242                    dict(
243                        event_type="vimgolf_result",
244                        event_data=dict(
245                            correct=correct, keys="".join(keycode_reprs), score=score
246                        ),
247                    )
248                )
249            )
250            results.append(result)
251
252            write("Here are your keystrokes:", color="green")
253            for keycode_repr in keycode_reprs:
254                color = "magenta" if len(keycode_repr) > 1 else None
255                write(keycode_repr, color=color, end=None)
256            write("")
257
258            if correct:
259                write("Success! Your output matches.", color="green")
260                write("Your score:", color="green")
261            else:
262                write(
263                    "Uh oh, looks like your entry does not match the desired output.",
264                    color="red",
265                )
266                write("Your score for this failed attempt:", color="red")
267            write(score)
268
269            upload_eligible = challenge.id and challenge.compliant and challenge.api_key
270
271            while True:
272                # Generate the menu items inside the loop since it can change across iterations
273                # (e.g., upload option can be removed)
274                with open(infile, "w") as f:
275                    f.write(challenge.in_text)
276                with open(outfile, "w") as f:
277                    f.write(challenge.out_text)
278                menu = []
279                if not correct:
280                    menu.append(("d", "Show diff"))
281                if upload_eligible and correct:
282                    menu.append(("w", "Upload result"))
283                menu.append(("r", "Retry the current challenge"))
284                menu.append(("q", "Quit vimgolf"))
285                valid_codes = [x[0] for x in menu]
286                for option in menu:
287                    write("[{}] {}".format(*option), color="yellow")
288                selection = input_loop("Choice> ")
289                if selection not in valid_codes:
290                    write(
291                        "Invalid selection: {}".format(selection),
292                        stream=sys.stderr,
293                        color="red",
294                    )
295                elif selection == "d":
296                    # diffsplit is used instead of 'vim -d' to avoid the "2 files to edit" message.
297                    diff_args = [
298                        "-n",
299                        outfile,
300                        "-c",
301                        "vertical diffsplit {}".format(infile),
302                    ]
303                    if VimRunner.run(diff_args) != Status.SUCCESS:
304                        return Status.FAILURE
305                elif selection == "w":
306                    upload_status = upload_result(
307                        challenge.id, challenge.api_key, raw_keys
308                    )
309                    if upload_status == Status.SUCCESS:
310                        write("Uploaded entry!", color="green")
311                        leaderboard_url = get_challenge_url(challenge.id)
312                        write(
313                            "View the leaderboard: {}".format(leaderboard_url),
314                            color="green",
315                        )
316                        upload_eligible = False
317                    else:
318                        write(
319                            "The entry upload has failed",
320                            stream=sys.stderr,
321                            color="red",
322                        )
323                        message = "Please check your API key on vimgolf.com"
324                        write(message, stream=sys.stderr, color="red")
325                else:
326                    break
327            if selection == "q":
328                break
329            write("Retrying vimgolf challenge", color="yellow")
330
331        write("Thanks for playing!", color="green")
332        return Status.SUCCESS
333
334
335if __name__ == "__main__":
336    main()
def parse_commandline_arguments():
44def parse_commandline_arguments():
45    """
46    Parse the command line arguments using argparse.
47
48    Args:
49        None
50
51    Returns:
52        A Namespace object with the parsed arguments.
53
54    The parsed arguments are:
55        - --input_file: str, the path to the input file
56        - --output_file: str, the path to the output file
57        - --log_file: str, the path to the log file
58        - --buffer_file: str, the path to the buffer file
59        - --init_keys: str, the initial keys to type into Vim
60    """
61
62    parser = argparse.ArgumentParser()
63    parser.add_argument("--input_file", type=str, required=True)
64    parser.add_argument("--output_file", type=str, required=True)
65    parser.add_argument("--log_file", type=str, required=True)
66    parser.add_argument("--buffer_file", type=str, default=None)
67    parser.add_argument("--init_keys", type=str, default="")
68    args = parser.parse_args()
69    return args

Parse the command line arguments using argparse.

Arguments:
  • None
Returns:

A Namespace object with the parsed arguments.

The parsed arguments are:
  • --input_file: str, the path to the input file
  • --output_file: str, the path to the output file
  • --log_file: str, the path to the log file
  • --buffer_file: str, the path to the buffer file
  • --init_keys: str, the initial keys to type into Vim
def main():
72def main():
73    """
74    Parse command line arguments and execute the vimgolf local challenge.
75
76    It sets up the logging to a file and then calls the local function to execute
77    the challenge.
78
79    The local function is called with the input file, output file, buffer file and init keys.
80
81    """
82    args = parse_commandline_arguments()
83    log_file = args.log_file
84    file_handler = logging.FileHandler(log_file)
85    file_handler.setLevel(logging.DEBUG)
86    logger.addHandler(file_handler)
87    if args.buffer_file:
88        local(
89            infile=args.input_file,
90            outfile=args.output_file,
91            buffer_file=args.buffer_file,
92            init_keys=args.init_keys,
93        )
94    else:
95        local(
96            infile=args.input_file,
97            outfile=args.output_file,
98            init_keys=args.init_keys,
99        )

Parse command line arguments and execute the vimgolf local challenge.

It sets up the logging to a file and then calls the local function to execute the challenge.

The local function is called with the input file, output file, buffer file and init keys.

def local( infile: str, outfile: str, buffer_file: Optional[str] = None, init_keys=''):
102def local(
103    infile: str, outfile: str, buffer_file: typing.Optional[str] = None, init_keys=""
104):
105    """
106    Execute a local VimGolf challenge.
107
108    It reads the input file, output file, and runs the challenge. The challenge
109    is defined by the input and output text and the file extensions.
110
111    Args:
112        infile: str, the path to the input file
113        outfile: str, the path to the output file
114        init_keys: str, the initial keys to type into Vim
115        buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
116
117    Returns:
118        Status: The status of the challenge
119    """
120    logger.info("local(%s, %s)", infile, outfile)
121    with open(infile, "r") as f:
122        in_text = format_(f.read())
123    with open(outfile, "r") as f:
124        out_text = format_(f.read())
125    _, in_extension = os.path.splitext(infile)
126    _, out_extension = os.path.splitext(outfile)
127    challenge = Challenge(
128        in_text=in_text,
129        out_text=out_text,
130        in_extension=in_extension,
131        out_extension=out_extension,
132        id=None,
133        compliant=None,
134        api_key=None,
135        init_keys=init_keys,
136    )
137    status = play(challenge, buffer_file=buffer_file)
138    return status

Execute a local VimGolf challenge.

It reads the input file, output file, and runs the challenge. The challenge is defined by the input and output text and the file extensions.

Arguments:
  • infile: str, the path to the input file
  • outfile: str, the path to the output file
  • init_keys: str, the initial keys to type into Vim
  • buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
Returns:

Status: The status of the challenge

def play(challenge, results=None, buffer_file: Optional[str] = None):
141def play(challenge, results=None, buffer_file: typing.Optional[str] = None):
142    """
143    Execute a VimGolf challenge.
144
145    Args:
146        challenge: Challenge, the challenge to play
147        results: list of Result, the results of previous plays
148        buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
149
150    Returns:
151        Status: The status of the challenge
152    """
153    if results is None:
154        results = []
155    logger.info("play(...)")
156    with tempfile.TemporaryDirectory() as workspace, working_directory(workspace):
157        infile = "in"
158        if challenge.in_extension:
159            infile += challenge.in_extension
160        outfile = "out"
161        if challenge.out_extension:
162            outfile += challenge.out_extension
163        logfile = "log"
164        with open(outfile, "w") as f:
165            f.write(challenge.out_text)
166
167        try:
168            # If there were init keys specified, we need to convert them to a
169            # form suitable for feedkeys(). We can't use Vim's -s option since
170            # it takes escape codes, not key codes. See Vim #4041 and TODO.txt
171            # ("Bug: script written with "-W scriptout" contains Key codes,
172            # while the script read with "-s scriptin" expects escape codes").
173            # The conversion is conducted here so that we can fail fast on
174            # error (prior to playing) and to avoid repeated computation.
175            keycode_reprs = tokenize_keycode_reprs(challenge.init_keys)
176            init_feedkeys = []
177            for item in keycode_reprs:
178                if item == "\\":
179                    item = "\\\\"  # Replace '\' with '\\'
180                elif item == '"':
181                    item = '\\"'  # Replace '"' with '\"'
182                elif item.startswith("<") and item.endswith(">"):
183                    item = "\\" + item  # Escape special keys ("<left>" -> "\<left>")
184                init_feedkeys.append(item)
185            init_feedkeys = "".join(init_feedkeys)
186        except Exception:
187            logger.exception("invalid init keys")
188            write("Invalid keys: {}".format(challenge.init_keys), color="red")
189            return Status.FAILURE
190
191        write("Launching vimgolf session", color="yellow")
192        while True:
193            with open(infile, "w") as f:
194                f.write(challenge.in_text)
195            with open(outfile, "w") as f:
196                f.write(challenge.out_text)
197            if buffer_file:
198                _prepare_cybergod_vimrc_with_buffer_file(buffer_file)
199                vimrc = _CYBERGOD_VIMGOLF_VIMRC_FILEPATH
200            else:
201                vimrc = _VIMGOLF_VIMRC_FILEPATH
202            play_args = [
203                "-Z",  # restricted mode, utilities not allowed
204                "-n",  # no swap file, memory only editing
205                "--noplugin",  # no plugins
206                "-i",
207                "NONE",  # don't load .viminfo (e.g., has saved macros, etc.)
208                "+0",  # start on line 0
209                "-u",
210                vimrc,  # vimgolf .vimrc
211                "-U",
212                "NONE",  # don't load .gvimrc
213                "-W",
214                logfile,  # keylog file (overwrites existing)
215                '+call feedkeys("{}", "t")'.format(init_feedkeys),  # initial keys
216                infile,
217            ]
218            if VimRunner.run(play_args) != Status.SUCCESS:
219                return Status.FAILURE
220
221            correct = filecmp.cmp(infile, outfile, shallow=False)
222            logger.info("correct: %s", str(correct).lower())
223            with open(logfile, "rb") as _f:
224                # raw keypress representation saved by vim's -w
225                raw_keys = _f.read()
226
227            # list of parsed keycode byte strings
228            keycodes = parse_keycodes(raw_keys)
229            keycodes = [
230                keycode for keycode in keycodes if keycode not in IGNORED_KEYSTROKES
231            ]
232
233            # list of human-readable key strings
234            keycode_reprs = [get_keycode_repr(keycode) for keycode in keycodes]
235            logger.info("keys: %s", "".join(keycode_reprs))
236
237            score = len(keycodes)
238            logger.info("score: %d", score)
239
240            result = Result(correct=correct, keys="".join(keycode_reprs), score=score)
241            logger.info(
242                json.dumps(
243                    dict(
244                        event_type="vimgolf_result",
245                        event_data=dict(
246                            correct=correct, keys="".join(keycode_reprs), score=score
247                        ),
248                    )
249                )
250            )
251            results.append(result)
252
253            write("Here are your keystrokes:", color="green")
254            for keycode_repr in keycode_reprs:
255                color = "magenta" if len(keycode_repr) > 1 else None
256                write(keycode_repr, color=color, end=None)
257            write("")
258
259            if correct:
260                write("Success! Your output matches.", color="green")
261                write("Your score:", color="green")
262            else:
263                write(
264                    "Uh oh, looks like your entry does not match the desired output.",
265                    color="red",
266                )
267                write("Your score for this failed attempt:", color="red")
268            write(score)
269
270            upload_eligible = challenge.id and challenge.compliant and challenge.api_key
271
272            while True:
273                # Generate the menu items inside the loop since it can change across iterations
274                # (e.g., upload option can be removed)
275                with open(infile, "w") as f:
276                    f.write(challenge.in_text)
277                with open(outfile, "w") as f:
278                    f.write(challenge.out_text)
279                menu = []
280                if not correct:
281                    menu.append(("d", "Show diff"))
282                if upload_eligible and correct:
283                    menu.append(("w", "Upload result"))
284                menu.append(("r", "Retry the current challenge"))
285                menu.append(("q", "Quit vimgolf"))
286                valid_codes = [x[0] for x in menu]
287                for option in menu:
288                    write("[{}] {}".format(*option), color="yellow")
289                selection = input_loop("Choice> ")
290                if selection not in valid_codes:
291                    write(
292                        "Invalid selection: {}".format(selection),
293                        stream=sys.stderr,
294                        color="red",
295                    )
296                elif selection == "d":
297                    # diffsplit is used instead of 'vim -d' to avoid the "2 files to edit" message.
298                    diff_args = [
299                        "-n",
300                        outfile,
301                        "-c",
302                        "vertical diffsplit {}".format(infile),
303                    ]
304                    if VimRunner.run(diff_args) != Status.SUCCESS:
305                        return Status.FAILURE
306                elif selection == "w":
307                    upload_status = upload_result(
308                        challenge.id, challenge.api_key, raw_keys
309                    )
310                    if upload_status == Status.SUCCESS:
311                        write("Uploaded entry!", color="green")
312                        leaderboard_url = get_challenge_url(challenge.id)
313                        write(
314                            "View the leaderboard: {}".format(leaderboard_url),
315                            color="green",
316                        )
317                        upload_eligible = False
318                    else:
319                        write(
320                            "The entry upload has failed",
321                            stream=sys.stderr,
322                            color="red",
323                        )
324                        message = "Please check your API key on vimgolf.com"
325                        write(message, stream=sys.stderr, color="red")
326                else:
327                    break
328            if selection == "q":
329                break
330            write("Retrying vimgolf challenge", color="yellow")
331
332        write("Thanks for playing!", color="green")
333        return Status.SUCCESS

Execute a VimGolf challenge.

Arguments:
  • challenge: Challenge, the challenge to play
  • results: list of Result, the results of previous plays
  • buffer_file: typing.Optional[str]: Where to write the vim editor buffer. Defaults to None.
Returns:

Status: The status of the challenge