5
\$\begingroup\$

I've made a controller for Python's Turtle. The controller essentially defines an 'instruction set' so that commands can be recorded in a text file and then executed sequentially by the turtle. It acts similarly to (underlying) assembly code in that it executes these instructions in order and can also do simple iteration, and has its own program counter.

This is the Turtle class:

import turtle

class Turtle(object):
    def __init__(self, command_filename="", turtle_name="Terry", speed=6):
        """Initialise turtle with filename, name and a speed."""
        self.mrTurtle = turtle.Turtle()
        self.set_speed(speed)
        self.set_name(turtle_name)
        if command_filename:
            self.load_commands(command_filename)
        self.pc = 0
        self.loop_counters = {}

    def load_commands(self, filename):
        """Load the command file and set self.commands"""
        lines_out = []
        try:
            with open(filename, "r") as inFile:
                for line in inFile.readlines():
                    comment = line.find(';')
                    if comment != -1:
                        line = line[:comment]
                    line = line.strip()
                    if len(line) > 0:
                        lines_out.append(line)
        except FileNotFoundError:
            print(f"File {filename} not found.")
        if lines_out[0].startswith("name"):
            self.set_name(lines_out[0].split()[1])
            lines_out = lines_out[1:]
        self.commands = lines_out
        self.queue = lines_out

    def Run(self):
        """Run all commands in buffer"""
        print(f"{self.name} is ready to go!")
        self.pc = 0
        while self.pc < len(self.commands):
            self.run_command(self.commands[self.pc])
            self.pc += 1
        print(f"{self.name} is finished!")

    def Step(self, steps=1):
        """Step through specified number of commands. 1 if no number specified."""
        for i in range(steps):
            try:
                self.run_command(self.commands[self.pc])
                self.pc += 1
            except IndexError:
                self.pc = 0
                self.run_command(self.commands[self.pc])
                self.pc += 1

    def StepThrough(self):
        """Run all commands, but wait for input between each command."""
        self.pc = 0
        while self.pc < len(self.commands):
            input("Press Enter.")
            self.run_command(self.commands[self.pc])
            self.pc += 1

    def queue_command(self, command):
        """Add a command to the buffer"""
        self.commands.append(command)

    def run_command(self, command):
        """Send 1 command to turtle. Big switch case handles command."""
        print(f"{self.name} is trying {command}")
        command = command.lower()
        try:
            command = command.split(" ")
            if len(command) > 1:
                data = [convert(item) for item in command[1:]]
            else:
                data = None
            command = command[0]
        except:
            print(f"Error: {self.name} could not perform command {command}")
            return None

        if command in ["forward", "forwards", "f", "fwd"]:
            self.forward(data[0])
        elif command in ["backward", "backwards", "back", "b", "bck"]:
            self.backward(data[0])
        elif command in ["left", "l", "lft"]:
            self.turn(-data[0])
        elif command in ["right", "r", "turn", "rgt", "trn"]:
            self.turn(data[0])
        elif command in ["move", "goto", "mv", "mov"]:
            self.set_position(data)
        elif command in ["reset", "origin", "centre", "cnt"]:
            self.set_position([0, 0])
        elif command in ["circle", "crc"]:
            self.circle(data)
        elif command in ["stamp", "print", "prt", "smp"]:
            self.stamp()
        elif command in ["undo", "und"]:
            if data is None:
                self.undo(1)
            else:
                self.undo(data[0])
        elif command in ["face", "setheading", "fac"]:
            self.face(data[0])
        elif command in ["loop", "startloop", "lop"]:
            if data[1] in self.loop_counters:
                pass
            else:
                self.loop_counters[data[1]] = [data[0], self.pc]
        elif command in ["endloop", "end"]:
            loop_name = data[0]
            loop_data = self.loop_counters[loop_name]
            if loop_data[0] > 1:
                self.pc = loop_data[1]
                self.loop_counters[loop_name][0] -= 1
            else:
                del self.loop_counters[loop_name]
        elif command in ["colour", "color", "setcolour", "clr"]:
            self.colour(data)
        elif command in ["penup", "up"]:
            self.penup()
        elif command in ["pendown"]:
            self.pendown()
        elif command in ["pen"]:
            self.switchpen()
        else:
            print(f"{self.name} doesn't know how to do {command}")

    def forward(self, data):
        """Go forwards a certain amount."""
        self.mrTurtle.forward(data)

    def backward(self, data):
        """Go backwards"""
        self.mrTurtle.backward(data)

    def turn(self, data):
        """Turn clockwise specified amount in degrees. Commands 'left' and 'right' both
        map to this function. Commands for 'left' are made negative."""
        self.mrTurtle.right(data)

    def face(self, data):
        """Face a certain heading."""
        self.mrTurtle.setheading(data)

    def set_position(self, data):
        """Move turtle to coordinates"""
        x = data[0]
        y = data[1]
        self.mrTurtle.setposition(x, y)

    def circle(self, data):
        """Draw a circle with the built in turtle circle function."""
        radius = data[0]
        extent = None
        steps = None
        if len(data) == 2:
            extent = data[1]
        if len(data) == 3:
            extent = data[1]
            steps = data[2]
        self.mrTurtle.circle(radius, extent, steps)

    def colour(self, data):
        """Modify turtle colour."""
        if len(data) == 1:
            data = data[0]
        elif len(data) == 3:
            data = (data[0], data[1], data[2])
        else:
            return None
        self.mrTurtle.colour(data)


    def penup(self):
        """Pen up."""
        self.mrTurtle.penup()

    def pendown(self):
        """Pen down."""
        self.mrTurtle.pendown()

    def switchpen(self):
        """Flip pen state."""
        if self.mrTurtle.isdown():
            self.mrTurtle.penup()
        else:
            self.mrTurtle.pendown()

    def stamp(self):
        """Stamp a copy of turtle."""
        self.mrTurtle.stamp()

    def undo(self, num):
        """Undo specified number of commands"""
        for i in range(num):
            self.mrTurtle.undo()

    def set_speed(self, speed):
        """Set the movement speed of turtle."""
        self.mrTurtle.speed(speed)

    def set_name(self, name):
        """Set the turtle name"""
        self.name = name

    def done(self):
        """Forces window to stay open after completion. Function for runturtle.py. No
        more commands can be executed after this one."""
        turtle.done()


def convert(input):
    """
    Converts a string to an int or float.
    Converts a list of strings into a list of strings, ints or floats.
    Args:
        input (str, list[str]): string or list of strings to attempt to convert.
    Returns:
        (str, int, float, list[...]): Depending on data contained in input.
    """
    if isinstance(input, str):
        input = [input]
    converted = []
    for item in input:
        try:
            if "." in item:
                converted.append(float(item))
            else:
                converted.append(int(item))
        except ValueError:
            converted.append(item)
    if len(converted) == 1:
        converted = converted[0]
    return converted

Here is what an example of a command file looks like:

name Loopy

loop 10 top ;loops MUST be named at both the top and the bottom of the loop
forwards 40

loop 4 square ;loop syntax is "loop [number of iterations] [loop name]"
left 90
forwards 10
endloop square

left 36
endloop top

My code is contained in this GitHub repo if you want to download and run it. It comes with a second script that can be run from the command line. There are also example files for the turtle that show some of the exciting ways it can be used.

I know a fat elif isn't the best way to go here, as this will be a nightmare to extend and maintain. What are some better options for how to implement this?

I also want to extend this, so it is more of a complete language. I want to implement while loops, some form of memory (likely just storing integers), and selection. What would be the right way of doing this?

If you think of any other features, additions or changes, I would like to hear those too :)

\$\endgroup\$
0

1 Answer 1

2
\$\begingroup\$

Classes (and functions) should ideally do one thing and do it well. The Python Zen also stipulates "There should be one-- and preferably only one --obvious way to do it."

Your class goes against this principle in two ways:

  • You have many different ways to actually execute the commands. There is Run, Step and StepThrough.
  • You have multiple keywords for the same command. Note how e.g. Python has only if, not also when, case, or a second syntax like if ... fi.

Your loop structure is also a bit weird. Files like this should raise a SyntaxError (or your equivalent), not be silently ignored:

name ThisShouldCrashAndBurn
loop 10 one
loop 10 two
forwards 1
endloop one
backwards 1
endloop two

I also have no idea what your code outputs for this, or should output. It will depend entirely on the implementation how this is handled.

Instead, just keep a stack of loop counters around. You always increment the last counter and pop it from the loop counters if a loop ends. This way you always end the innermost loop.

Here is a slightly simplified version, which removes duplicate keywords and uses the simplified loopcounter. It also gives the user a bit of the control back (by not providing e.g. a method to add a command).

I also removed the other run methods. Now the run method yields after each step, so it is the user's responsibility to loop. I did add a convenience method that does not pause.

The whole thing directly inherits from turtle.Turtle, and uses the method names as keywords, where applicable. This eliminates all the trivial commands. It has a whitelist of all commands to block access to other turtle commands, although you might want to remove that to allow access to all of them directly.

import turtle

class Turtle(turtle.Turtle):
    keywords = {"forward", "backward", "left", "right", "setposition",
                "reset", "circle", "colour", "penup", "pendown",
                "switchpen", "undo", "name", "done", "speed", "name",
                "loop", "endloop"}

    def __init__(self, commands, speed=6):
        """Initialise turtle with a speed."""
        super().__init__()
        self.speed(speed)
        self.name("terry")
        self.pointer = 0
        self.loops = []
        self.commands = commands

    @classmethod
    def from_file(cls, file_name, speed=6):
        with open(file_name) as f:
            commands = [line[:line.find(";")].strip().lower()
                        for line in f]
        return cls(commands, speed)

    def run(self):
        """Run commands in buffer, pausing after each step"""
        print(f"{self._name} is ready to go!")
        # need a while loop here because the pointer might be moved by the command
        self.pointer = 0
        while self.pointer < len(self.commands):
            self.run_command(self.commands[self.pointer])
            yield self.pointer
            self.pointer += 1
        print(f"{self._name} is finished!")

    def run_all(self):
        """Run all commands."""
        for _ in self.run():
            pass

    def run_command(self, command):
        """Send 1 command to turtle. Big switch case handles command."""
        print(f"{self._name} is trying {command}")
        try:
            command, *data = command.split(" ")
            data = list(map(convert, data))
        except:
            print(f"Error: {self._name} could not perform command {command}")
            return None
        if command in self.keywords:
            getattr(self, command)(*data)
        else:
            print(f"{self._name} doesn't know how to do {command}")

    def loop(self, iterations):
        self.loops.append([iterations, self.pointer])

    def endloop(self):
        try:
            n, start = self.loops[-1]
        except IndexError:
            print("No loop to end")
            return
        if n == 1:
            self.loops.pop()
        else:
            self.loops[-1][0] -= 1
            self.pointer = start

    def reset(self):
        self.setposition(0, 0)

    def circle(self, radius, extent=None, steps=None):
        """Draw a circle with the built in turtle circle function."""
        super().circle(radius, extent, steps)

    def switchpen(self):
        """Flip pen state."""
        if self.isdown():
            self.penup()
        else:
            self.pendown()

    def undo(self, num=1):
        """Undo specified number of commands"""
        for _ in range(num):
            super().undo()

    def name(self, name):
        """Set the turtle name"""
        self._name = name

    def done(self):
        """Forces window to stay open after completion. Function for runturtle.py. No
        more commands can be executed after this one."""
        turtle.done()


def convert(input):
    """
    Converts a string to an int or float.
    Converts a list of strings into a list of strings, ints or floats.
    Args:
        input (str, list[str]): string or list of strings to attempt to convert.
    Returns:
        (str, int, float, list[...]): Depending on data contained in input.
    """
    if isinstance(input, str):
        input = [input]
    converted = []
    for item in input:
        try:
            if "." in item:
                converted.append(float(item))
            else:
                converted.append(int(item))
        except ValueError:
            converted.append(item)
    if len(converted) == 1:
        converted = converted[0]
    return converted

if __name__ == "__main__":
    import sys
    t = Turtle.from_file(sys.argv[1])
    t.run_all()

Note that if data is the empty list (which is allowed by the extended tuple unpacking), the splatting using * will not interfere and allow even methods without arguments being run. I.e. the following works:

def f():
    pass

f(*[])
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Ahhh I see that. I really like having a loop stack instead of named loops. Makes it much simpler, and you were right the program simply kills itself if you put a loop in like that currently. Can't believe I forgot about getattr! That makes it a LOT better. Thankyou \$\endgroup\$
    – mattrea6
    Nov 18, 2019 at 16:34

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.