├── .circleci └── config.yml ├── .gitignore ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── build ├── copy.sh ├── docker-compose.yaml └── dockerfile ├── docker ├── docker-compose.yaml └── dockerfile ├── gdrepl ├── __init__.py ├── __main__.py ├── client.py ├── commands.py ├── constants.py ├── find_godot.py ├── gdserver.gd ├── gdserverv4.gd ├── main.py └── websocketserver.gd ├── irc_bot ├── bot.py ├── config.py.example ├── message_server.py └── requirements.txt ├── requirements.txt └── setup.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | workflows: 3 | build_and_deploy: 4 | jobs: 5 | - build: 6 | filters: 7 | tags: 8 | only: /.*/ 9 | - test-python-install: 10 | version: "3.8" 11 | requires: 12 | - build 13 | - test-python-install: 14 | version: "3.9" 15 | requires: 16 | - build 17 | - test-python-install: 18 | version: "3.10" 19 | requires: 20 | - build 21 | - deploy: 22 | requires: 23 | - build 24 | filters: 25 | tags: 26 | only: /^v[0-9]+(\.[0-9]+)*$/ 27 | branches: 28 | ignore: /.*/ 29 | 30 | jobs: 31 | build: 32 | docker: 33 | - image: cimg/python:3.9 34 | steps: 35 | - checkout 36 | - run: 37 | name: install python dependencies 38 | command: | 39 | python3 -m venv venv 40 | . venv/bin/activate 41 | pip install -r requirements.txt 42 | paths: 43 | - "venv" 44 | 45 | test-python-install: 46 | parameters: 47 | version: 48 | type: string 49 | default: latest 50 | docker: 51 | - image: cimg/python:<< parameters.version >> 52 | steps: 53 | - checkout 54 | - run: 55 | name: install python dependencies 56 | command: | 57 | python3 -m venv venv 58 | . venv/bin/activate 59 | pip install -r requirements.txt 60 | - run: 61 | name: Smoke Test Install 62 | command: | 63 | python3 --version 64 | pip install . 65 | 66 | deploy: 67 | docker: 68 | - image: cimg/python:3.9 69 | steps: 70 | - run: 71 | name: Checkout 72 | command: | 73 | git clone https://github.com/matheusfillipe/GDScript-REPL . 74 | git fetch --all 75 | - run: 76 | name: install python dependencies 77 | command: | 78 | python3 -m venv venv 79 | . venv/bin/activate 80 | pip install -r requirements.txt 81 | - run: 82 | name: init .pypirc 83 | command: | 84 | echo -e "[pypi]" >> ~/.pypirc 85 | echo -e "username = $PYPI_USERNAME" >> ~/.pypirc 86 | echo -e "password = $PYPI_PASSWORD" >> ~/.pypirc 87 | - run: 88 | name: create packages 89 | command: | 90 | . venv/bin/activate 91 | pip install wheel 92 | python3 setup.py sdist bdist_wheel 93 | - run: 94 | name: upload to pypi 95 | command: | 96 | pip install twine 97 | twine upload dist/* 98 | 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | docker/godot 3 | docker/logs/ 4 | venv/ 5 | botvenv/ 6 | irc_bot/config.py 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Released under MIT License 2 | 3 | Copyright (c) 2022 Matheus Fillipe. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include gdrepl/*.gd 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI Build Status](https://circleci.com/gh/matheusfillipe/GDScript-REPL.svg?style=shield)](https://circleci.com/gh/matheusfillipe/GDScript-REPL) 2 | [![Pypi](https://badge.fury.io/py/gdrepl.svg)](https://pypi.org/project/gdrepl/) 3 | [![Chat with me on irc](https://img.shields.io/badge/-IRC-gray?logo=gitter)](https://mangle.ga/irc) 4 | 5 | [![demo](https://user-images.githubusercontent.com/24435787/176273963-dfce8324-665d-4136-a155-66d8db687332.gif)](https://asciinema.org/a/504811) 6 | 7 | # GDScript REPL 8 | 9 | This repository contains: 10 | 11 | - A proof of concept GDScript REPL 12 | - A dockerfile to build godot server for alpine 13 | - A dockerfile to run godot from alpine 14 | - A IRC GDScript REPL bot 15 | 16 | Notice that if all you want is run GDScript files from the command line you don't need this project. Check out: https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html 17 | 18 | 19 | ## Motivation 20 | 21 | GDScript is a python like language but it lacks a REPL. Godot has a built in `godot -s script.gd` to run scripts but it is overkill when you just want to test out the difference between a `PoolStringArray` and a normal `Array` of strings and play around like you can do with so many languages. 22 | 23 | That inspired me to try to turn `godot -s` into a REPL, creating a [websocket server](https://docs.godotengine.org/en/stable/classes/class_websocketserver.html) that will take any string from any client in, evaluate it by creating new [GDScript](https://docs.godotengine.org/en/stable/classes/class_script.html), attaching that script to a resource node and then calling a function of that node. That requires a lot of hacky string manipulations to keep stuff working and have a separated local and global scopes allowing you to create functions, enums and classes from the REPL. 24 | 25 | This is this still very work in progress and experimental but serves to prove the point that a REPL for godot would be awesome. 26 | 27 | ## Installation 28 | 29 | Simply: 30 | ```bash 31 | pip3 install gdrepl 32 | 33 | gdrepl 34 | ``` 35 | 36 | If you want to use the irc bot you will need to clone this repo and follow the instructions bellow. 37 | 38 | 39 | ## Usage 40 | 41 | The GDScript server is implemented in a way that it will send the return output to the client but not stdout. So if you type `1+1` you will receive `2` but you can't receive `print(2)` event though that will be still shown on the server's output. 42 | 43 | Currently this doesn't perfectly support multiline and you have to manually fix the indentation sometimes. You can also "fake" multiline input in a single line in both the irc bot and REPL by using a `;`. Those will be replaced to `\n` at runtime, for example: 44 | 45 | ```GDScript 46 | func inc(value):; var new = value + 1; return value 47 | ``` 48 | 49 | It supports godot 3 and 4. If you don't have godot on your path use the `--godot` parameter to pass it or `--command` to completely change the godot command, like if you don't want it headless, and in that case you have to manually specify `-s path/sto/gdserver.gd`. Example: 50 | 51 | ```bash 52 | gdrepl --godot /home/user/programs/godot/godot 53 | ``` 54 | 55 | If you are having problems try running the server and client separately. In one terminal run: 56 | 57 | ```bash 58 | gdrepl server --verbose # You can also pass --godot or --command 59 | ``` 60 | 61 | In another one: 62 | 63 | ```bash 64 | gdrepl client # maybe --port N 65 | ``` 66 | 67 | Notice that multiple clients can be connected to the same server. 68 | 69 | For more information check `gdrepl --help`, `gdrepl server --help` etc. 70 | 71 | 72 | ## Development 73 | 74 | ### CLI 75 | 76 | Requires python3 77 | 78 | 1. Install godot headless. Ubuntu has `sudo apt install godot3-server` which is very suitable for this. In another distros without that the script will fallback to `godot --no-window` to run it headlessly. 79 | 3. You can create a virtual environment or not: `pip3 install requirements.py` 80 | 4. Run `python -m gdrepl` 81 | 82 | With this you will see both stdout and return output in the same window. 83 | 84 | ### Server 85 | 86 | Start the server with: 87 | 88 | ```bash 89 | gdrepl server 90 | ``` 91 | 92 | ```bash 93 | $ python -m gdrepl --help 94 | Welcome to GDScript REPL. Hit Ctrl+C to exit. If you start having errors type 'clear' 95 | Usage: python -m gdrepl [OPTIONS] COMMAND [ARGS]... 96 | 97 | Options: 98 | --help Show this message and exit. 99 | 100 | Commands: 101 | REPL* Launch the godot server and start the REPL 102 | client Connects to a running godot REPL server 103 | server Starts the GDScript REPL websocket server 104 | 105 | ``` 106 | 107 | 108 | Alternatively you can directly run the godot script: 109 | ```bash 110 | godot3-server --script gdserver.gd 111 | # or 112 | godot --no-window --script gdserver.gd 113 | ``` 114 | 115 | You can connect to this server using any websocket client. I recommend [websocat](https://github.com/vi/websocat): 116 | 117 | ```bash 118 | websocat ws://127.0.0.1:9080 119 | 120 | # Or if you have rlwrap installed (You should) 121 | rlwrap websocat ws://127.0.0.1:9080 122 | ``` 123 | 124 | The problem with this is that stdout and stderr will be displayed on the server while only the return will be shown on the client. 125 | 126 | To connect to the server you can run: `gdrepl client`. You can then type `help` to see what special server commands you can run like `script_code` to check what currently generated script is. 127 | 128 | Custom server commands are: 129 | 130 | ```json 131 | { 132 | "reset": "clears the script buffer for the current session", 133 | "script_local": "Sends back the generated local", 134 | "script_global": "Sends back the generated global", 135 | "script_code": "Sends back the generated full runtime script code", 136 | "dellast_local": "Deletes last local scope or code block", 137 | "delline_local": "Deletes certain line number from the local script", 138 | "delline_global": "Deletes certain line number from the global script", 139 | "delglobal": "Deletes the entire global scope", 140 | "dellocal": "Deletes the entire local scope", 141 | "quit": "stops this server", 142 | } 143 | 144 | ``` 145 | 146 | 147 | ### Environtment variables 148 | 149 | If `DEBUG=1` is set then the server will keep writing the formed script to stdout. 150 | 151 | If `TEST=1` the websocket server won't run and simple test functions will be executed. 152 | 153 | ### Why the weird approach 154 | 155 | My main goal was to make a safe to host irc bot REPL, spawning a docker image for each command. The `OS` module contains dangerous functions that allow you to run shell commands. In that process I realized it would be easy to make a normal CLI REPL as well. 156 | 157 | ## Run the IRC bot 158 | 159 | Requires python3 and a godot image. Use the `irc_bot` folder. 160 | 161 | 1. Install gdrepl: `pip3 install gdrepl` 162 | 2. Build a docker image for it (See section bellow). 163 | 3. Copy `config.py.example` to `config.py` 164 | 4. Edit it for your needs. 165 | 5. You can create a virtual environment or not: `pip3 install bot/requirements.py` 166 | 6. Run `./bot.py` 167 | 168 | To keep it running and manage it i recommend pm2: https://pm2.keymetrics.io/ 169 | 170 | 171 | 172 | ## Docker 173 | 174 | ### Build GODOT 175 | 176 | To build godot for your platform run `docker-compose build` inside the `build/` directory. Then run `./copy.sh` and the resulting binary will be inside `build/bin/`. 177 | 178 | This was only tested on AARCH64. If you want to run the irc bot in a supported platform simply grab godot-server from https://godotengine.org/download/server 179 | 180 | ### Build and image for the irc bot 181 | 182 | If you build godot, put the binary inside the `docker` folder and rename it to `godot`. Then inside `docker/` run `docker-compose build` and you will have it. You can also use the same docker image you build it from, just update the bot config accordingly. 183 | 184 | ### Why the dockerfile doesn't download godot binary automatically? 185 | 186 | Because I hate when I try to use one of those docker images but they are x86_64 only. Godot is fairly easy to build. 187 | If you are on aarch64 like me (Raspberry Pi 4, oracle Ampere A1) this is how I build godot: 188 | ```bash 189 | scons arch=arm64 platform=server target=release_debug use_llvm=no colored=yes pulseaudio=no CFLAGS="$CFLAGS -fPIC -Wl,-z,relro,-z,now" CXXFLAGS="$CXXFLAGS -fPIC -Wl,-z,relro,-z,now" LINKFLAGS="$LDFLAGS" -j4 190 | strip bin/godot* 191 | ``` 192 | 193 | 194 | # TODO 195 | 196 | - [ ] Have the GDScript websocket server be a proper api or protoccol using json or something less hacky than we have now 197 | - [ ] GDScript methods and properties auto completion using godot lsp 198 | - [ ] Auto unindent (like with else, elif) 199 | -------------------------------------------------------------------------------- /build/copy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copies the godot build from the host 3 | 4 | id=$(docker create build_godot) 5 | docker cp $id:/godot/bin . 6 | docker rm -v $id 7 | -------------------------------------------------------------------------------- /build/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | godot: 5 | build: . 6 | tty: true 7 | container_name: godot-build 8 | -------------------------------------------------------------------------------- /build/dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | # Environment Variables 4 | ENV GODOT_VERSION "3.4.4" 5 | ENV ARCH "arm64" 6 | 7 | # Updates and installs 8 | RUN apk update 9 | RUN apk add --no-cache bash wget git libc6-compat scons pkgconf gcc g++ libx11-dev libxcursor-dev libxinerama-dev libxi-dev libxrandr-dev libexecinfo-dev 10 | 11 | # Get source code 12 | RUN wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.31-r0/glibc-2.31-r0.apk 13 | RUN apk add --allow-untrusted glibc-2.31-r0.apk 14 | 15 | # BUILD 16 | RUN git clone https://github.com/godotengine/godot.git -b ${GODOT_VERSION}-stable --depth=1 17 | WORKDIR /godot/ 18 | RUN scons arch=${ARCH} platform=server target=release_debug use_llvm=no colored=yes pulseaudio=no p=linuxbsd execinfo=yes CFLAGS="$CFLAGS -fPIC -Wl,-z,relro,-z,now" CXXFLAGS="$CXXFLAGS -fPIC -Wl,-z,relro,-z,now" LINKFLAGS="$LDFLAGS" -j4 && strip bin/godot* 19 | 20 | 21 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Taken from github: GodotNuts/GodotServer-Docker 2 | # 3 | version: '3' 4 | 5 | services: 6 | godot: 7 | build: . 8 | ports: 9 | - "9080:9080" 10 | tty: true 11 | container_name: godot-repl-server 12 | -------------------------------------------------------------------------------- /docker/dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | # Environment Variables 4 | ENV GODOT_BINARY "godot" 5 | 6 | # Updates and installs 7 | RUN apk update 8 | RUN apk add --no-cache bash wget libc6-compat libexecinfo-dev 9 | 10 | # Allow this to run Godot 11 | RUN wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.31-r0/glibc-2.31-r0.apk 12 | RUN apk add --allow-untrusted glibc-2.31-r0.apk 13 | 14 | # Download Godot, version is set from environment variables 15 | COPY ${GODOT_BINARY} /usr/bin/godot 16 | RUN chmod +x /usr/bin/godot 17 | 18 | # Make directory and user to run unpriviledged 19 | RUN addgroup -S godot && adduser -S godot -G godot 20 | USER godot 21 | WORKDIR /home/godot 22 | 23 | RUN mkdir ~/.cache \ 24 | && mkdir -p ~/.config/godot 25 | 26 | # Check if godot can run 27 | RUN ldd /usr/bin/godot 28 | 29 | ENTRYPOINT ["godot", "-s", "gdserver.gd"] 30 | -------------------------------------------------------------------------------- /gdrepl/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import client 2 | from .find_godot import find_godot, godot_command, script_file, script_file_v4 3 | from .main import run, server 4 | 5 | __all__ = ("client", "find_godot", "run", "server", 6 | "script_file", "script_file_v4", "godot_command") 7 | -------------------------------------------------------------------------------- /gdrepl/__main__.py: -------------------------------------------------------------------------------- 1 | from .main import cli 2 | if __name__ == "__main__": 3 | cli() 4 | -------------------------------------------------------------------------------- /gdrepl/client.py: -------------------------------------------------------------------------------- 1 | from websocket import create_connection 2 | from .constants import HOST, PORT 3 | 4 | class client: 5 | def __init__(self, host=HOST, port=PORT): 6 | try: 7 | self.ws = create_connection(f"ws://{host}:{port}") 8 | except ConnectionRefusedError: 9 | print("Could not connect to server") 10 | exit(1) 11 | 12 | def close(self): 13 | self.ws.close() 14 | 15 | def send(self, msg: str, get_response=True) -> str: 16 | """Converts ';' to '\n' and sends the message to the server, returning its response""" 17 | 18 | self.ws.send(msg.replace(";", "\n")) 19 | 20 | if not get_response: 21 | return "" 22 | 23 | resp = self.ws.recv().decode() 24 | # Return response 25 | if resp.startswith(">> "): 26 | if len(resp) > 3: 27 | if resp[3:] == "Err: 43": 28 | return 29 | return " -> " + resp[3:] 30 | return 31 | 32 | if resp == "Cleared": 33 | return "Environment cleared!" 34 | 35 | return resp 36 | -------------------------------------------------------------------------------- /gdrepl/commands.py: -------------------------------------------------------------------------------- 1 | # Client commands for the repl 2 | 3 | import os 4 | from pathlib import Path 5 | from .client import client 6 | from .constants import SCRIPT_LOAD_REMOVE_KWDS, STDOUT_MARKER_END, STDOUT_MARKER_START 7 | from types import FunctionType 8 | 9 | from dataclasses import dataclass 10 | 11 | from prompt_toolkit.completion import (Completer, PathCompleter, WordCompleter) 12 | from prompt_toolkit.shortcuts import clear 13 | 14 | EMPTY_COMPLETER = WordCompleter([]) 15 | 16 | 17 | def EMPTYFUNC(*args): 18 | pass 19 | 20 | 21 | @dataclass 22 | class Command: 23 | completer: Completer = EMPTY_COMPLETER 24 | help: str = "" 25 | do: FunctionType = EMPTYFUNC 26 | send_to_server: bool = False 27 | 28 | 29 | # COMMANDS 30 | 31 | def _help(*args): 32 | print("REPL SPECIAL COMMANDS") 33 | for cmd in COMMANDS: 34 | print(f"{cmd}: {COMMANDS[cmd].help}") 35 | 36 | 37 | def loadscript(c: client, args): 38 | """Reads all contents from each of the args file and sends it to the server.""" 39 | for file in args: 40 | file = str(Path(file).expanduser()) 41 | if not os.path.isfile(file): 42 | print("File does not exist") 43 | return 44 | with open(file, 'r') as f: 45 | for line in f.readlines(): 46 | if line.strip() and line.split()[0] in SCRIPT_LOAD_REMOVE_KWDS: 47 | continue 48 | c.send(line) 49 | c.send("\n") 50 | print("\n\nSuccessfully loaded script(s)") 51 | 52 | 53 | def savescript(c: client, args): 54 | """Saves the contents of the server to the file specified by the args.""" 55 | script_global = c.send("script_global") 56 | script_local = c.send("script_local") 57 | # Check if directory exists 58 | if not os.path.isdir(Path(args[0]).parent): 59 | print("Directory does not exist") 60 | return 61 | 62 | with open(args[0], 'w') as f: 63 | f.write(script_global) 64 | local_buffer = "" 65 | for line in script_local.split("\n"): 66 | # Remove STDOUT print lines 67 | if line.strip() == "print(\"" + STDOUT_MARKER_START + "\")": 68 | continue 69 | if line.strip() == "print(\"" + STDOUT_MARKER_END + "\")": 70 | continue 71 | local_buffer += line + "\n" 72 | f.write(script_local) 73 | print("\n\nSuccessfully saved script to " + args[0]) 74 | 75 | 76 | COMMANDS = { 77 | "load": Command(completer=PathCompleter(), help="Load .gd file into this session", do=loadscript), 78 | "save": Command(completer=PathCompleter(), help="Save this session to .gd file", do=savescript), 79 | "quit": Command(help="Finishes this repl"), 80 | "help": Command(help="Displays this message", do=_help), 81 | "clear": Command(help="Clears the screen", do=lambda _, __: clear()), 82 | } 83 | -------------------------------------------------------------------------------- /gdrepl/constants.py: -------------------------------------------------------------------------------- 1 | # Godot executable 2 | GODOT = "godot" 3 | 4 | # Websocket server 5 | PORT = 1580 6 | HOST = "127.0.0.1" 7 | 8 | # When trying to find a port the godot server can bind on 9 | # This also means that this is the maximum simultaneous repls that can run 10 | MAX_PORT_BIND_ATTEMPTS = 100 11 | 12 | 13 | # Possible commands to launch godot 14 | POSSIBLE_COMMANDS = [ 15 | "godot3-server", 16 | "godot --no-window", 17 | "godot-editor --no-window", 18 | "/Applications/Godot.app/Contents/MacOS/Godot --no-window", 19 | "godot4 --headless", 20 | ] 21 | 22 | 23 | 24 | # CLI prompt options 25 | # If set to false the prompt will have emacs bindings 26 | VI = False 27 | 28 | # Godot keywords 29 | KEYWORDS = ["if", "elif", "else", "for", "while", "match", "break", "continue", "pass", "return", "class", 30 | "class_name", "extends", "is", "as", "self", "tool", "signal", "func", "static", "const", "enum", "var", "print", "printerr"] 31 | 32 | SCRIPT_LOAD_REMOVE_KWDS = ["tool", "extends", "onready", "@onready"] 33 | 34 | 35 | STDOUT_MARKER_START = "----------------STDOUT-----------------------" 36 | STDOUT_MARKER_END = "----------------STDOUT END-----------------------" 37 | -------------------------------------------------------------------------------- /gdrepl/find_godot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | from pathlib import Path 4 | import subprocess 5 | from shutil import which 6 | 7 | from .constants import MAX_PORT_BIND_ATTEMPTS, POSSIBLE_COMMANDS 8 | 9 | def is_tool(name): 10 | """Check whether `name` is on PATH and marked as executable.""" 11 | # Check if name is a path that exists as executable or is a command 12 | return (which(name) is not None) or (os.path.exists(name) and os.access(name, os.X_OK)) 13 | 14 | 15 | def script_file(): 16 | return str(Path(__file__).parent.resolve() / Path("gdserver.gd")) 17 | 18 | 19 | def script_file_v4(): 20 | return str(Path(__file__).parent.resolve() / Path("gdserverv4.gd")) 21 | 22 | 23 | def find_godot(): 24 | """Finds the godot executable.""" 25 | for cmd in POSSIBLE_COMMANDS: 26 | if is_tool(cmd.split()[0]): 27 | return cmd 28 | print("Cannot find godot executable. You may use --godot") 29 | return "" 30 | 31 | 32 | def godot_command(godot: str) -> str: 33 | """Fixes the arguments for godot based on the version""" 34 | output = None 35 | try: 36 | output = subprocess.run(f"{godot} --version", stdout=subprocess.PIPE, timeout=None, 37 | check=False, shell=True, stderr=subprocess.STDOUT).stdout.decode().strip() 38 | except subprocess.CalledProcessError: 39 | if not output: 40 | print("Failed to check godot version!") 41 | return godot 42 | 43 | script = script_file() 44 | 45 | # Godot doesn't care about repeated flags so just to make sure 46 | if output.startswith("3"): 47 | godot.replace("--headless", "--no-window") 48 | godot += " --no-window" 49 | 50 | if output.startswith("4"): 51 | godot.replace("--no-window", "--headless") 52 | godot += " --headless" 53 | script = script_file_v4() 54 | 55 | return f"{godot} --script {script}" 56 | 57 | 58 | def find_available_port(start_port: int) -> int: 59 | """Starts checking if start_port is available to bind and increments 1 until it finds an available port.""" 60 | port = start_port 61 | while True: 62 | if port - start_port > MAX_PORT_BIND_ATTEMPTS: 63 | print("Failed to find free port. Maybe I can't bind on localhost?") 64 | import sys 65 | sys.exit(1) 66 | return -1 67 | 68 | try: 69 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 70 | sock.bind(("localhost", port)) 71 | sock.close() 72 | return port 73 | 74 | except OSError: 75 | # print(port, "is busy, trying next port") 76 | port += 1 77 | -------------------------------------------------------------------------------- /gdrepl/gdserver.gd: -------------------------------------------------------------------------------- 1 | # Simple implementation for a exec/eval function in gdscript 2 | # that can accept functions and classes and will return the output 3 | 4 | extends SceneTree 5 | 6 | # Simple protocol: 7 | # Send anything else to evaluate as godot code 8 | # Commands: 9 | const commands = { 10 | "reset": "clears the script buffer for the current session", 11 | "script_local": "Sends back the generated local", 12 | "script_global": "Sends back the generated global", 13 | "script_code": "Sends back the generated full runtime script code", 14 | "dellast_local": "Deletes last local scope or code block", 15 | "delline_local": "Deletes certain line number from the local script", 16 | "delline_global": "Deletes certain line number from the global script", 17 | "delglobal": "Deletes the entire global scope", 18 | "dellocal": "Deletes the entire local scope", 19 | "quit": "stops this server", 20 | } 21 | 22 | const STDOUT_MARKER_START = "----------------STDOUT-----------------------" 23 | const STDOUT_MARKER_END = "----------------STDOUT END-----------------------" 24 | 25 | # The port we will listen to. 26 | const PORT = 9080 27 | # Our WebSocketServer instance. 28 | var _server = WebSocketServer.new() 29 | var port = PORT 30 | 31 | var sessions = {} 32 | var debug = false 33 | 34 | var loop = true 35 | 36 | # These are scope initializer keywords_global. In gdscript these can't 37 | # go inside another one of themselves 38 | const keywords_global = ["func", "class", "enum", "static", "const", "export"] 39 | 40 | # Wont try to return if last line starts with any of those 41 | const keywords_local = ["if", "else", "while", "for", "break", "continue", "var", "const"] 42 | 43 | # Function that will be called on eval 44 | # This means that users wont be able to define this name 45 | const mainfunc = "___eval" 46 | const main = "func " + mainfunc + "():\n" 47 | 48 | enum Scope { 49 | Global, 50 | Yellow, 51 | Local, 52 | } 53 | 54 | class Session: 55 | var global = "" 56 | var local = "" 57 | var scope = Scope.Local 58 | var last_scope_begin_index 59 | 60 | # Another reason not to add return is if in a local scope like if, elif, for... 61 | var local_scope_lock = false 62 | 63 | func is_global() -> bool: 64 | return scope == Scope.Global 65 | 66 | func dellast_local(): 67 | var i = 0 68 | var new_local = "" 69 | for line in local.strip_edges().split("\n"): 70 | if i >= last_scope_begin_index: 71 | break 72 | new_local += line + "\n" 73 | i += 1 74 | local = new_local 75 | 76 | func check_scope(line: String, index: int): 77 | var has_keyword = line.split(" ")[0] in keywords_local 78 | var is_continuation = line.split(" ")[0].rstrip(":") in ["else", "elif"] 79 | 80 | if not local_scope_lock: 81 | last_scope_begin_index = index 82 | if has_keyword and not is_continuation: 83 | local_scope_lock = true 84 | elif not is_continuation and local_scope_lock and not line.begins_with(" "): 85 | local_scope_lock = false 86 | last_scope_begin_index = index 87 | return has_keyword 88 | 89 | func get_last_scope_index(): 90 | var i = 0 91 | for line in local.strip_edges().split("\n"): 92 | check_scope(line, i) 93 | i += 1 94 | return last_scope_begin_index 95 | 96 | 97 | # Generates script code for the session 98 | func code() -> String: 99 | if len(local.strip_edges()) == 0: 100 | return global 101 | 102 | var _local = main 103 | var lines = Array(local.strip_edges().split("\n")) 104 | var last_stripped = lines[-1].strip_edges() 105 | 106 | # In the local scope 107 | var last_index = get_last_scope_index() 108 | var i = 0 109 | if len(lines) > 1: 110 | local_scope_lock = false 111 | for line in lines.slice(0, len(lines)-2): 112 | var has_keyword = check_scope(line, i) 113 | 114 | # Removes all calls to print except the last one or keyword one 115 | if i == last_index: 116 | var identation = " ".repeat(len(line.rstrip(" ")) - len(line.rstrip(" ").lstrip(" "))) 117 | _local += " " + identation + "print(\"" + STDOUT_MARKER_START + "\")" + "\n" 118 | _local += " " + line + "\n" 119 | 120 | i += 1 121 | 122 | # Removes all calls to print except the last one or keyword one 123 | if i == last_index: 124 | _local += " " + "print(\"" + STDOUT_MARKER_START + "\")" + "\n" 125 | 126 | var has_keyword = check_scope(lines[-1], len(lines) - 1) 127 | 128 | # Only put return on local if it is really needed 129 | var is_assignment = "=" in last_stripped and not "==" in last_stripped 130 | if has_keyword or is_assignment or local_scope_lock or lines[-1].begins_with(" "): 131 | _local += " " + lines[-1] 132 | else: 133 | _local += " return " + lines[-1] 134 | 135 | return global + "\n" + _local 136 | 137 | func delline(num: int, code: String) -> String: 138 | var lines = Array(code.split("\n")) 139 | var new_code = "" 140 | var i = 1 141 | for line in lines: 142 | if i == num: 143 | continue 144 | new_code += line + "\n" 145 | i += 1 146 | return new_code 147 | 148 | func dellocal(line: int): 149 | local = delline(line, local) 150 | 151 | func delglobal(line: int): 152 | global = delline(line, global) 153 | 154 | func copy(): 155 | var s = Session.new() 156 | s.global = global 157 | s.local = local 158 | return s 159 | 160 | 161 | 162 | # Useful for debuging 163 | func print_script(script, session): 164 | if not debug: 165 | return 166 | print(">>>> ", session) 167 | print("-----------------------------------") 168 | print(script.source_code) 169 | print("-----------------------------------\n") 170 | 171 | func add_code(code: String, session: String = "main"): 172 | # Switch to global scope on keywords_global 173 | if code != main and code.strip_edges().split(" ")[0] in keywords_global: 174 | sessions[session].scope = Scope.Global 175 | if debug: 176 | print(">>--------global switch-----------<<") 177 | 178 | elif sessions[session].is_global() and not code.begins_with(" "): 179 | sessions[session].scope = Scope.Local 180 | if debug: 181 | print(">>---------global off-------------<<") 182 | 183 | if sessions[session].is_global() or sessions[session].scope == Scope.Yellow: 184 | sessions[session].global += code 185 | else: 186 | sessions[session].local += code 187 | 188 | # Executes the the input code and returns the output 189 | # The code will accumulate on the session 190 | func exec(input: String, session: String = "main") -> String: 191 | # Initializes a script for that session 192 | if not session in sessions: 193 | sessions[session] = Session.new() 194 | 195 | var lines = Array(input.split("\n")) 196 | 197 | # Appends each input line correctly idented to the eval funcion of the script 198 | for line in lines.slice(0, len(lines)-1): 199 | if len(line) > 0: 200 | add_code(line + "\n", session) 201 | 202 | if sessions[session].is_global(): 203 | return "" 204 | 205 | if sessions[session].scope == Scope.Yellow: 206 | sessions[session].scope = Scope.Local 207 | return "" 208 | 209 | var script = GDScript.new() 210 | script.source_code = sessions[session].code() 211 | print_script(script, session) 212 | 213 | var err = script.reload() 214 | if err != OK: 215 | sessions[session].dellast_local() 216 | return "Err: " + str(err) 217 | 218 | var obj = Reference.new() 219 | obj.set_script(script) 220 | 221 | if mainfunc in script.source_code: 222 | var res = str(obj.call(mainfunc)) 223 | print(STDOUT_MARKER_END) 224 | return res 225 | 226 | return "" 227 | 228 | # Clear a session 229 | func clear(session: String = "main"): 230 | sessions.erase(session) 231 | 232 | func _init(): 233 | if OS.has_environment("TEST") and OS.get_environment("TEST").to_lower() in ["true", "1"]: 234 | test() 235 | quit() 236 | return 237 | 238 | if OS.has_environment("DEBUG") and OS.get_environment("DEBUG").to_lower() in ["true", "1"]: 239 | debug = true 240 | 241 | if OS.has_environment("PORT"): 242 | port = int(OS.get_environment("PORT")) 243 | 244 | 245 | # We dont need those but good to know 246 | #_server.connect("client_connected", self, "_connected") 247 | #_server.connect("client_disconnected", self, "_disconnected") 248 | #_server.connect("client_close_request", self, "_close_request") 249 | _server.connect("data_received", self, "_on_data") 250 | 251 | # Start listening on the given port. 252 | var err = _server.listen(port) 253 | if err != OK: 254 | print("Unable to start server") 255 | print("Gdrepl Listening on ", port) 256 | while loop: 257 | OS.delay_msec(50) 258 | _process() 259 | 260 | free() 261 | quit() 262 | 263 | 264 | # Tests 265 | ############# 266 | const cmd0 = "1+1" 267 | const cmd1 = "Array(\"what is this man\".split(' '))[-1]" 268 | const cmd2 = """ 269 | var a = Array(\"hello world man\".split(\" \")) 270 | a.sort() 271 | print(a[0]) 272 | a 273 | """ 274 | const cmd21 = "var a = 1" 275 | const cmd22 = "a+3" 276 | const cmd3 = """ 277 | func hi(): 278 | print('hi') 279 | return 24 280 | 281 | hi() 282 | """ 283 | 284 | const cmd4 = """ 285 | func add(a, b): 286 | return a+b+hi() 287 | 1 + add(2, 3) 288 | """ 289 | 290 | func test(): 291 | debug = true 292 | 293 | var session = "main" 294 | if OS.has_environment("SESSION") and session == "main": 295 | session = OS.get_environment("SESSION") 296 | 297 | print(exec(cmd0, session)) 298 | clear(session) 299 | print(exec(cmd1, session)) 300 | clear(session) 301 | print(exec(cmd2, session)) 302 | clear(session) 303 | print(exec(cmd21, session)) 304 | print(exec(cmd22, session)) 305 | print(exec(cmd3, session)) 306 | print(exec(cmd4, session)) 307 | debug = false 308 | 309 | 310 | func _on_data(id): 311 | var pkt = _server.get_peer(id).get_packet() 312 | var data = pkt.get_string_from_utf8() 313 | if debug: 314 | print("Got data from client %d: %s" % [id, data]) 315 | 316 | var session = "main" 317 | if OS.has_environment("SESSION") and session == "main": 318 | session = OS.get_environment("SESSION") 319 | 320 | 321 | # Commands without arguments 322 | var cmd = data.strip_edges().to_lower() 323 | var response = "" 324 | var has_command = true 325 | match cmd : 326 | "quit": 327 | _server.stop() 328 | loop = false 329 | quit() 330 | return 331 | "help": 332 | response = "GDREPL Server Help\n" 333 | for c in commands: 334 | response += c + ": " + commands[c] + "\n" 335 | response += "\n" 336 | 337 | "reset": 338 | clear(session) 339 | response = "Cleared" 340 | 341 | "script_local": 342 | if session in sessions: 343 | response = sessions[session].local 344 | 345 | "script_global": 346 | if session in sessions: 347 | response = sessions[session].global 348 | 349 | "script_code": 350 | if session in sessions: 351 | response = sessions[session].code() 352 | 353 | "dellast_local": 354 | sessions[session].dellast_local() 355 | 356 | "delglobal": 357 | sessions[session].global = "" 358 | 359 | "dellocal": 360 | sessions[session].local = "" 361 | 362 | _: 363 | has_command = false 364 | 365 | if has_command: 366 | if len(response) == 0: 367 | response = "-" 368 | send(id, response) 369 | return 370 | 371 | # Commands with arguments 372 | cmd = data.strip_edges().split(" ")[0].to_lower() 373 | match cmd: 374 | "delline_local": 375 | sessions[session].dellocal(int(data.split(" ")[1])) 376 | response = "Deleted line" 377 | 378 | "delline_global": 379 | sessions[session].delglobal(int(data.split(" ")[1])) 380 | response = "Deleted line" 381 | 382 | _: 383 | response = exec(data, session) 384 | send(id, ">> " + response) 385 | return 386 | 387 | send(id, response) 388 | 389 | 390 | func send(id, data): 391 | _server.get_peer(id).put_packet(data.to_utf8()) 392 | 393 | 394 | func _process(): 395 | _server.poll() 396 | 397 | func free(): 398 | _server.stop() 399 | .free() 400 | -------------------------------------------------------------------------------- /gdrepl/gdserverv4.gd: -------------------------------------------------------------------------------- 1 | # Simple implementation for a exec/eval function in gdscript 2 | # that can accept functions and classes and will return the output 3 | 4 | extends SceneTree 5 | 6 | const WebSocketServer = preload("websocketserver.gd") 7 | 8 | # Simple protocol: 9 | # Send anything else to evaluate as godot code 10 | # Commands: 11 | const commands = { 12 | "reset": "clears the script buffer for the current session", 13 | "script_local": "Sends back the generated local", 14 | "script_global": "Sends back the generated global", 15 | "script_code": "Sends back the generated full runtime script code", 16 | "dellast_local": "Deletes last local scope or code block", 17 | "delline_local": "Deletes certain line number from the local script", 18 | "delline_global": "Deletes certain line number from the global script", 19 | "delglobal": "Deletes the entire global scope", 20 | "dellocal": "Deletes the entire local scope", 21 | "quit": "stops this server", 22 | } 23 | 24 | const STDOUT_MARKER_START = "----------------STDOUT-----------------------" 25 | const STDOUT_MARKER_END = "----------------STDOUT END-----------------------" 26 | 27 | # The port we will listen to. 28 | const PORT = 9080 29 | # Our WebSocketServer instance. 30 | var _server = WebSocketServer.new() 31 | var port = PORT 32 | 33 | var sessions = {} 34 | var debug = false 35 | 36 | var loop = true 37 | 38 | # These are scope initializer keywords_global. In gdscript these can't 39 | # go inside another one of themselves 40 | const keywords_global = ["func", "class", "enum", "static", "const", "export"] 41 | 42 | # Wont try to return if last line starts with any of those 43 | const keywords_local = ["if", "else", "while", "for", "break", "continue", "var", "const"] 44 | 45 | # Function that will be called on eval 46 | # This means that users wont be able to define this name 47 | const mainfunc = "___eval" 48 | const main = "func " + mainfunc + "():\n" 49 | 50 | enum Scope { 51 | Global, 52 | Yellow, 53 | Local, 54 | } 55 | 56 | class Session: 57 | var global = "" 58 | var local = "" 59 | var scope = Scope.Local 60 | var last_scope_begin_index 61 | 62 | # Another reason not to add return is if in a local scope like if, elif, for... 63 | var local_scope_lock = false 64 | 65 | func is_global() -> bool: 66 | return scope == Scope.Global 67 | 68 | func dellast_local(): 69 | var i = 0 70 | var new_local = "" 71 | for line in local.strip_edges().split("\n"): 72 | if i >= last_scope_begin_index: 73 | break 74 | new_local += line + "\n" 75 | i += 1 76 | local = new_local 77 | 78 | func check_scope(line: String, index: int): 79 | var has_keyword = line.split(" ")[0] in keywords_local 80 | var is_continuation = line.split(" ")[0].rstrip(":") in ["else", "elif"] 81 | 82 | if not local_scope_lock: 83 | last_scope_begin_index = index 84 | if has_keyword and not is_continuation: 85 | local_scope_lock = true 86 | elif not is_continuation and local_scope_lock and not line.begins_with(" "): 87 | local_scope_lock = false 88 | last_scope_begin_index = index 89 | return has_keyword 90 | 91 | func get_last_scope_index(): 92 | var i = 0 93 | for line in local.strip_edges().split("\n"): 94 | check_scope(line, i) 95 | i += 1 96 | return last_scope_begin_index 97 | 98 | 99 | # Generates script code for the session 100 | func code() -> String: 101 | if len(local.strip_edges()) == 0: 102 | return global 103 | 104 | var _local = main 105 | var lines = Array(local.strip_edges().split("\n")) 106 | var last_stripped = lines[-1].strip_edges() 107 | 108 | # In the local scope 109 | var last_index = get_last_scope_index() 110 | var i = 0 111 | if len(lines) > 1: 112 | local_scope_lock = false 113 | for line in lines.slice(0, len(lines)-1): 114 | var has_keyword = check_scope(line, i) 115 | 116 | # Removes all calls to print except the last one or keyword one 117 | if i == last_index: 118 | var identation = " ".repeat(len(line.rstrip(" ")) - len(line.rstrip(" ").lstrip(" "))) 119 | _local += " " + identation + "print(\"" + STDOUT_MARKER_START + "\")" + "\n" 120 | _local += " " + line + "\n" 121 | 122 | i += 1 123 | 124 | # Removes all calls to print except the last one or keyword one 125 | if i == last_index: 126 | _local += " " + "print(\"" + STDOUT_MARKER_START + "\")" + "\n" 127 | 128 | var has_keyword = check_scope(lines[-1], len(lines) - 1) 129 | 130 | # Only put return on local if it is really needed 131 | var is_assignment = "=" in last_stripped and not "==" in last_stripped 132 | if has_keyword or is_assignment or local_scope_lock or lines[-1].begins_with(" "): 133 | _local += " " + lines[-1] 134 | else: 135 | _local += " return " + lines[-1] 136 | 137 | return global + "\n" + _local 138 | 139 | func delline(num: int, code: String) -> String: 140 | var lines = Array(code.split("\n")) 141 | var new_code = "" 142 | var i = 1 143 | for line in lines: 144 | if i == num: 145 | continue 146 | new_code += line + "\n" 147 | i += 1 148 | return new_code 149 | 150 | func dellocal(line: int): 151 | local = delline(line, local) 152 | 153 | func delglobal(line: int): 154 | global = delline(line, global) 155 | 156 | func copy(): 157 | var s = Session.new() 158 | s.global = global 159 | s.local = local 160 | return s 161 | 162 | 163 | 164 | # Useful for debuging 165 | func print_script(script, session): 166 | if not debug: 167 | return 168 | print(">>>> ", session) 169 | print("-----------------------------------") 170 | print(script.source_code) 171 | print("-----------------------------------\n") 172 | 173 | func add_code(code: String, session: String = "main"): 174 | # Switch to global scope on keywords_global 175 | if code != main and code.strip_edges().split(" ")[0] in keywords_global: 176 | sessions[session].scope = Scope.Global 177 | if debug: 178 | print(">>--------global switch-----------<<") 179 | 180 | elif sessions[session].is_global() and not code.begins_with(" "): 181 | sessions[session].scope = Scope.Local 182 | if debug: 183 | print(">>---------global off-------------<<") 184 | 185 | if sessions[session].is_global() or sessions[session].scope == Scope.Yellow: 186 | sessions[session].global += code 187 | else: 188 | sessions[session].local += code 189 | 190 | # Executes the the input code and returns the output 191 | # The code will accumulate on the session 192 | func exec(input: String, session: String = "main") -> String: 193 | # Initializes a script for that session 194 | if not session in sessions: 195 | sessions[session] = Session.new() 196 | 197 | var lines = Array(input.split("\n")) 198 | 199 | # Appends each input line correctly idented to the eval funcion of the script 200 | for line in lines.slice(0, len(lines)): 201 | if len(line) > 0: 202 | add_code(line + "\n", session) 203 | 204 | if sessions[session].is_global(): 205 | return "" 206 | 207 | if sessions[session].scope == Scope.Yellow: 208 | sessions[session].scope = Scope.Local 209 | return "" 210 | 211 | var script = GDScript.new() 212 | script.source_code = sessions[session].code() 213 | print_script(script, session) 214 | 215 | var err = script.reload() 216 | if err != OK: 217 | sessions[session].dellast_local() 218 | return "Err: " + str(err) 219 | 220 | var obj = RefCounted.new() 221 | obj.set_script(script) 222 | 223 | if mainfunc in script.source_code: 224 | print(STDOUT_MARKER_START) 225 | var res = str(obj.call(mainfunc)) 226 | print(STDOUT_MARKER_END) 227 | return res 228 | return "" 229 | 230 | # Clear a session 231 | func clear(session: String = "main"): 232 | sessions.erase(session) 233 | 234 | func _init(): 235 | if OS.has_environment("TEST") and OS.get_environment("TEST").to_lower() in ["true", "1"]: 236 | test() 237 | quit() 238 | return 239 | 240 | if OS.has_environment("DEBUG") and OS.get_environment("DEBUG").to_lower() in ["true", "1"]: 241 | debug = true 242 | 243 | if OS.has_environment("PORT"): 244 | port = OS.get_environment("PORT").to_int() 245 | 246 | 247 | _server.message_received.connect(_on_message) 248 | 249 | # Start listening on the given port. 250 | var err = _server.listen(port) 251 | if err != OK: 252 | print("Unable to start server") 253 | print("Gdrepl Listening on ", port) 254 | while loop: 255 | OS.delay_msec(50) 256 | _process(0) 257 | 258 | free() 259 | quit() 260 | 261 | 262 | # Tests 263 | ############# 264 | const cmd0 = "1+1" 265 | const cmd1 = "Array(\"what is this man\".split(' '))[-1]" 266 | const cmd2 = """ 267 | var a = Array(\"hello world man\".split(\" \")) 268 | a.sort() 269 | print(a[0]) 270 | a 271 | """ 272 | const cmd21 = "var a = 1" 273 | const cmd22 = "a+3" 274 | const cmd3 = """ 275 | func hi(): 276 | print('hi') 277 | return 24 278 | 279 | hi() 280 | """ 281 | 282 | const cmd4 = """ 283 | func add(a, b): 284 | return a+b+hi() 285 | 1 + add(2, 3) 286 | """ 287 | 288 | func test(): 289 | debug = true 290 | 291 | var session = "main" 292 | if OS.has_environment("SESSION") and session == "main": 293 | session = OS.get_environment("SESSION") 294 | 295 | print(exec(cmd0, session)) 296 | clear(session) 297 | print(exec(cmd1, session)) 298 | clear(session) 299 | print(exec(cmd2, session)) 300 | clear(session) 301 | print(exec(cmd21, session)) 302 | print(exec(cmd22, session)) 303 | print(exec(cmd3, session)) 304 | print(exec(cmd4, session)) 305 | debug = false 306 | 307 | 308 | func _on_message(id, message): 309 | if debug: 310 | print("Got message from client %d: %s" % [id, message]) 311 | 312 | var session = "main" 313 | if OS.has_environment("SESSION") and session == "main": 314 | session = OS.get_environment("SESSION") 315 | 316 | 317 | # Commands without arguments 318 | var cmd = message.strip_edges().to_lower() 319 | var response = "" 320 | var has_command = true 321 | match cmd : 322 | "quit": 323 | _server.stop() 324 | loop = false 325 | quit() 326 | return 327 | "help": 328 | response = "GDREPL Server Help\n" 329 | for c in commands: 330 | response += c + ": " + commands[c] + "\n" 331 | response += "\n" 332 | 333 | "reset": 334 | clear(session) 335 | response = "Cleared" 336 | 337 | "script_local": 338 | if session in sessions: 339 | response = sessions[session].local 340 | 341 | "script_global": 342 | if session in sessions: 343 | response = sessions[session].global 344 | 345 | "script_code": 346 | if session in sessions: 347 | response = sessions[session].code() 348 | 349 | "dellast_local": 350 | sessions[session].dellast_local() 351 | 352 | "delglobal": 353 | sessions[session].global = "" 354 | 355 | "dellocal": 356 | sessions[session].local = "" 357 | 358 | _: 359 | has_command = false 360 | 361 | if has_command: 362 | if len(response) == 0: 363 | response = "-" 364 | send(id, response) 365 | return 366 | 367 | # Commands with arguments 368 | cmd = message.strip_edges().split(" ")[0].to_lower() 369 | match cmd: 370 | "delline_local": 371 | sessions[session].dellocal((message.split(" ")[1]).to_int()) 372 | response = "Deleted line" 373 | 374 | "delline_global": 375 | sessions[session].delglobal((message.split(" ")[1]).to_int()) 376 | response = "Deleted line" 377 | 378 | _: 379 | response = exec(message, session) 380 | send(id, ">> " + response) 381 | return 382 | 383 | send(id, response) 384 | 385 | 386 | func send(id, data): 387 | _server.peers.get(id).put_packet(data.to_utf8_buffer()) 388 | 389 | func _process(_delta): 390 | _server.poll() 391 | 392 | func free(): 393 | _server.stop() 394 | # super().free() 395 | -------------------------------------------------------------------------------- /gdrepl/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess as sb 4 | 5 | import click 6 | import pexpect 7 | from dataclasses import dataclass 8 | from click_default_group import DefaultGroup 9 | from prompt_toolkit.completion import (WordCompleter, Completer) 10 | from prompt_toolkit.document import Document 11 | from prompt_toolkit.history import InMemoryHistory 12 | from prompt_toolkit.lexers import PygmentsLexer 13 | from prompt_toolkit.shortcuts import prompt 14 | from pygments.lexers.gdscript import GDScriptLexer 15 | 16 | from .client import client as wsclient 17 | from .constants import GODOT, KEYWORDS, PORT, VI, STDOUT_MARKER_END, STDOUT_MARKER_START 18 | from .commands import (Command, COMMANDS) 19 | from .find_godot import godot_command, find_available_port, find_godot 20 | 21 | TIMEOUT = 0.2 22 | 23 | GODOT = find_godot() 24 | 25 | history = InMemoryHistory() 26 | 27 | 28 | @dataclass 29 | class PromptOptions: 30 | vi: bool = False 31 | timeout: int = TIMEOUT 32 | 33 | 34 | class CustomCompleter(Completer): 35 | """Auto completion and commands""" 36 | 37 | def __init__(self): 38 | self.word_completer = WordCompleter( 39 | KEYWORDS + list(COMMANDS.keys()), WORD=True) 40 | self.document = None 41 | self.iterator = None 42 | 43 | def _create_iterator(self, completer, document, complete_event): 44 | self.iterator = completer.get_completions(document, complete_event) 45 | 46 | def get_completions(self, document, complete_event): 47 | # Only complete after 1st character of last word 48 | if len(document.text_before_cursor.split(" ")[-1]) < 1: 49 | return 50 | 51 | cmd = document.text.split()[0] 52 | if cmd in COMMANDS and len(document.text_before_cursor.strip()) > len(cmd): 53 | sub_doc = Document(document.text[len(cmd) + 1:]) 54 | self._create_iterator(COMMANDS[cmd].completer, 55 | sub_doc, complete_event) 56 | 57 | elif document != self.document: 58 | self.document = document 59 | self._create_iterator(self.word_completer, 60 | document, complete_event) 61 | for w in self.iterator: 62 | yield w 63 | 64 | 65 | def _prompt(options, completer, multiline=False, ident_level=0): 66 | return prompt( 67 | ">>> " if not multiline else "... ", 68 | vi_mode=options.vi, 69 | default=" " * ident_level, 70 | history=history, 71 | lexer=PygmentsLexer(GDScriptLexer), 72 | completer=completer, 73 | ) 74 | 75 | 76 | def wait_for_output(server, timeout): 77 | try: 78 | server.expect(STDOUT_MARKER_START, timeout=timeout) 79 | server.expect(STDOUT_MARKER_END, timeout=timeout) 80 | output = server.before.decode() 81 | if output.strip(): 82 | print(output.strip()) 83 | except pexpect.exceptions.TIMEOUT: 84 | pass 85 | try: 86 | server.expect(r"SCRIPT ERROR:(.+)", timeout=timeout) 87 | error = server.match.group(1).decode().strip() 88 | error = re.sub( 89 | "\r\n" + r".*ERROR:.* Method failed\..*" + "\r\n.*", "", error 90 | ) 91 | # if re.match(r".*SCRIPT ERROR:.*at:.*\(:(\d+)\).*", ) 92 | print(error) 93 | except pexpect.exceptions.TIMEOUT: 94 | pass 95 | 96 | 97 | def repl_loop(client, options: PromptOptions, server=None): 98 | # Fill out our auto completion with server commands as well 99 | helpmsg = client.send("help") 100 | for line in helpmsg.split("\n"): 101 | helplist = line.split(":") 102 | if len(helplist) != 2: 103 | continue 104 | 105 | cmd = helplist[0] 106 | help = helplist[1] 107 | 108 | # Let us override the server commands help message 109 | if cmd in COMMANDS: 110 | continue 111 | COMMANDS[cmd] = Command(help=help, send_to_server=True) 112 | 113 | completer = CustomCompleter() 114 | multiline_buffer = "" 115 | multiline = False 116 | ident_level = 0 117 | while True: 118 | try: 119 | cmd = _prompt(options, completer, multiline, ident_level) 120 | except KeyboardInterrupt: 121 | multiline = False 122 | multiline_buffer = "" 123 | ident_level = 0 124 | continue 125 | except EOFError: 126 | client.close() 127 | break 128 | 129 | if len(cmd.strip()) == 0: 130 | if not multiline: 131 | continue 132 | multiline = False 133 | # HACK force run 134 | multiline_buffer += ";" 135 | 136 | if cmd.strip() in ["quit", "exit"]: 137 | client.send(cmd, False) 138 | client.close() 139 | break 140 | 141 | if not multiline and len(cmd.split()) > 0 and cmd.split()[0] in COMMANDS: 142 | command = COMMANDS[cmd.split()[0]] 143 | command.do(client, cmd.split()[1:]) 144 | if not command.send_to_server: 145 | continue 146 | 147 | history._loaded_strings = list(dict.fromkeys(history._loaded_strings)) 148 | 149 | # Switch to multiline until return is pressed twice 150 | if cmd.strip().endswith(":"): 151 | multiline = True 152 | ident_level = len(cmd.rstrip()) - len(cmd.strip()) + 1 153 | 154 | multiline_buffer += cmd 155 | if multiline: 156 | multiline_buffer += "\n" 157 | continue 158 | 159 | resp = client.send(multiline_buffer) 160 | multiline_buffer = "" 161 | ident_level = 0 162 | 163 | if resp: 164 | print(resp) 165 | 166 | if server is not None: 167 | wait_for_output(server, options.timeout) 168 | 169 | 170 | def start_message(): 171 | print( 172 | "Welcome to GDScript REPL. Hit Ctrl+D to exit. If you start having errors type 'reset'" 173 | ) 174 | 175 | 176 | @click.group(cls=DefaultGroup, default="run", default_if_no_args=True) 177 | def cli(): 178 | pass 179 | 180 | 181 | @cli.command(help="Launch the godot server and starts the repl") 182 | @click.option("--vi", is_flag=True, default=VI, help="Use vi mode") 183 | @click.option("--godot", default=GODOT, help="Path to godot executable") 184 | @click.option( 185 | "--command", default="", help="Custom command to run the server script with" 186 | ) 187 | @click.option("--timeout", default=TIMEOUT, help="Time to wait for godot output") 188 | def run(vi, godot, command, timeout): 189 | if not godot: 190 | return 191 | server = None 192 | 193 | port = find_available_port(PORT) 194 | env = os.environ.copy() 195 | env["PORT"] = str(port) 196 | 197 | if command: 198 | server = pexpect.spawn(command, env=env) 199 | else: 200 | server = pexpect.spawn(godot_command(godot), env=env) 201 | server.expect(r".*Godot Engine (\S+) .*") 202 | version = server.match.group(1).decode().strip() 203 | print("Godot", version, "listening on:", port) 204 | 205 | server.expect("Gdrepl Listening on .*") 206 | 207 | start_message() 208 | client = wsclient(port=port) 209 | repl_loop(client, PromptOptions(vi=vi, timeout=timeout), server) 210 | 211 | 212 | @cli.command(help="Connects to a running godot repl server") 213 | @click.option("--vi", is_flag=True, default=VI, help="Use vi mode") 214 | @click.option("--port", default=PORT, help="Port to connect to") 215 | def client(vi, port): 216 | client = wsclient(port=port) 217 | start_message() 218 | print("Not launching server..") 219 | repl_loop(client, PromptOptions(vi=vi)) 220 | 221 | 222 | @cli.command(help="Starts the gdscript repl websocket server") 223 | @click.option("--godot", default=GODOT, help="Path to godot executable") 224 | @click.option("--port", default=PORT, help="Port to listen on") 225 | @click.option("--verbose", is_flag=True, default=False, help="Enable debug output") 226 | def server(port, godot, verbose): 227 | if not godot: 228 | return 229 | env = os.environ.copy() 230 | if port: 231 | env["PORT"] = str(port) 232 | else: 233 | env["PORT"] = str(find_available_port(port)) 234 | 235 | if verbose: 236 | env["DEBUG"] = "1" 237 | 238 | sb.run(godot_command(godot), shell=True, env=env) 239 | -------------------------------------------------------------------------------- /gdrepl/websocketserver.gd: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014-present Godot Engine contributors. 2 | # Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. 3 | 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | 10 | # Example taken from https://github.com/godotengine/godot-demo-projects/blob/c3b0331d8a06a2b8def14e745db9e24d0f387c80/networking/websocket_minimal/server.gd Modified to include copyright notice 11 | 12 | class_name WebSocketServer 13 | extends Node 14 | 15 | signal message_received(peer_id: int, message: String) 16 | signal client_connected(peer_id: int) 17 | signal client_disconnected(peer_id: int) 18 | 19 | @export var handshake_headers := PackedStringArray() 20 | @export var supported_protocols := PackedStringArray() 21 | @export var handshake_timout := 3000 22 | @export var use_tls := false 23 | @export var tls_cert: X509Certificate 24 | @export var tls_key: CryptoKey 25 | @export var refuse_new_connections := false: 26 | set(refuse): 27 | if refuse: 28 | pending_peers.clear() 29 | 30 | 31 | class PendingPeer: 32 | var connect_time: int 33 | var tcp: StreamPeerTCP 34 | var connection: StreamPeer 35 | var ws: WebSocketPeer 36 | 37 | func _init(p_tcp: StreamPeerTCP) -> void: 38 | tcp = p_tcp 39 | connection = p_tcp 40 | connect_time = Time.get_ticks_msec() 41 | 42 | 43 | var tcp_server := TCPServer.new() 44 | var pending_peers: Array[PendingPeer] = [] 45 | var peers: Dictionary 46 | 47 | 48 | func listen(port: int) -> int: 49 | assert(not tcp_server.is_listening()) 50 | return tcp_server.listen(port) 51 | 52 | 53 | func stop() -> void: 54 | tcp_server.stop() 55 | pending_peers.clear() 56 | peers.clear() 57 | 58 | 59 | func send(peer_id: int, message: String) -> int: 60 | var type := typeof(message) 61 | if peer_id <= 0: 62 | # Send to multiple peers, (zero = broadcast, negative = exclude one). 63 | for id: int in peers: 64 | if id == -peer_id: 65 | continue 66 | if type == TYPE_STRING: 67 | peers[id].send_text(message) 68 | else: 69 | peers[id].put_packet(message) 70 | return OK 71 | 72 | assert(peers.has(peer_id)) 73 | var socket: WebSocketPeer = peers[peer_id] 74 | if type == TYPE_STRING: 75 | return socket.send_text(message) 76 | return socket.send(var_to_bytes(message)) 77 | 78 | 79 | func get_message(peer_id: int) -> Variant: 80 | assert(peers.has(peer_id)) 81 | var socket: WebSocketPeer = peers[peer_id] 82 | if socket.get_available_packet_count() < 1: 83 | return null 84 | var pkt: PackedByteArray = socket.get_packet() 85 | if socket.was_string_packet(): 86 | return pkt.get_string_from_utf8() 87 | return bytes_to_var(pkt) 88 | 89 | 90 | func has_message(peer_id: int) -> bool: 91 | assert(peers.has(peer_id)) 92 | return peers[peer_id].get_available_packet_count() > 0 93 | 94 | 95 | func _create_peer() -> WebSocketPeer: 96 | var ws := WebSocketPeer.new() 97 | ws.supported_protocols = supported_protocols 98 | ws.handshake_headers = handshake_headers 99 | return ws 100 | 101 | 102 | func poll() -> void: 103 | if not tcp_server.is_listening(): 104 | return 105 | 106 | while not refuse_new_connections and tcp_server.is_connection_available(): 107 | var conn: StreamPeerTCP = tcp_server.take_connection() 108 | assert(conn != null) 109 | pending_peers.append(PendingPeer.new(conn)) 110 | 111 | var to_remove := [] 112 | 113 | for p in pending_peers: 114 | if not _connect_pending(p): 115 | if p.connect_time + handshake_timout < Time.get_ticks_msec(): 116 | # Timeout. 117 | to_remove.append(p) 118 | continue # Still pending. 119 | 120 | to_remove.append(p) 121 | 122 | for r: RefCounted in to_remove: 123 | pending_peers.erase(r) 124 | 125 | to_remove.clear() 126 | 127 | for id: int in peers: 128 | var p: WebSocketPeer = peers[id] 129 | p.poll() 130 | 131 | if p.get_ready_state() != WebSocketPeer.STATE_OPEN: 132 | client_disconnected.emit(id) 133 | to_remove.append(id) 134 | continue 135 | 136 | while p.get_available_packet_count(): 137 | message_received.emit(id, get_message(id)) 138 | 139 | for r: int in to_remove: 140 | peers.erase(r) 141 | to_remove.clear() 142 | 143 | 144 | func _connect_pending(p: PendingPeer) -> bool: 145 | if p.ws != null: 146 | # Poll websocket client if doing handshake. 147 | p.ws.poll() 148 | var state := p.ws.get_ready_state() 149 | if state == WebSocketPeer.STATE_OPEN: 150 | var id := randi_range(2, 1 << 30) 151 | peers[id] = p.ws 152 | client_connected.emit(id) 153 | return true # Success. 154 | elif state != WebSocketPeer.STATE_CONNECTING: 155 | return true # Failure. 156 | return false # Still connecting. 157 | elif p.tcp.get_status() != StreamPeerTCP.STATUS_CONNECTED: 158 | return true # TCP disconnected. 159 | elif not use_tls: 160 | # TCP is ready, create WS peer. 161 | p.ws = _create_peer() 162 | p.ws.accept_stream(p.tcp) 163 | return false # WebSocketPeer connection is pending. 164 | 165 | else: 166 | if p.connection == p.tcp: 167 | assert(tls_key != null and tls_cert != null) 168 | var tls := StreamPeerTLS.new() 169 | tls.accept_stream(p.tcp, TLSOptions.server(tls_key, tls_cert)) 170 | p.connection = tls 171 | p.connection.poll() 172 | var status: StreamPeerTLS.Status = p.connection.get_status() 173 | if status == StreamPeerTLS.STATUS_CONNECTED: 174 | p.ws = _create_peer() 175 | p.ws.accept_stream(p.connection) 176 | return false # WebSocketPeer connection is pending. 177 | if status != StreamPeerTLS.STATUS_HANDSHAKING: 178 | return true # Failure. 179 | 180 | return false 181 | 182 | 183 | func _process(_delta: float) -> void: 184 | poll() -------------------------------------------------------------------------------- /irc_bot/bot.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | import signal 4 | import threading 5 | from pathlib import Path 6 | from tempfile import NamedTemporaryFile 7 | 8 | import requests 9 | import trio 10 | from cachetools import TTLCache 11 | from IrcBot.bot import IrcBot, Message, utils 12 | from pexpect import replwrap 13 | 14 | from config import (CHANNELS, NICK, PORT, PREFIX, DOCKER_COMMAND, 15 | SERVER, SSL) 16 | 17 | from message_server import listen_loop 18 | file 19 | from gdrepl import script_file 20 | 21 | REPL_TTL = 60 * 60 * 2 22 | DOCKER_COMMAND = DOCKER_COMMAND % str(Path(script_file()).parent) 23 | COMMAND = f'gdrepl --command "{DOCKER_COMMAND}"' 24 | 25 | user_repls = TTLCache(maxsize=4, ttl=REPL_TTL) 26 | user_history = TTLCache(maxsize=128, ttl=REPL_TTL) 27 | 28 | utils.setHelpHeader( 29 | "USE: {PREFIX} [gdscript command here] - (Notice the space)") 30 | utils.setHelpBottom( 31 | "GDscript is the godot game engine language. Tutorial at https://gdscript.com/tutorials/") 32 | utils.setLogging(10) 33 | utils.setParseOrderTopBottom(True) 34 | utils.setPrefix(PREFIX) 35 | 36 | info = utils.log 37 | 38 | FIFO = NamedTemporaryFile(mode='w+b', prefix='gdrepl-bot', 39 | suffix='.fifo', delete=False).name 40 | 41 | def ansi2irc(text): 42 | """Convert ansi colors to irc colors.""" 43 | text = re.sub(r'\x1b\[([0-9;]+)m', lambda m: '\x03' + m.group(1), text) 44 | text = re.sub(r'\x1b\[([0-9;]+)[HJK]', lambda m: '\x1b[%s%s' % 45 | (m.group(1), m.group(2)), text) 46 | return text 47 | 48 | 49 | def reply(msg: Message, text: str): 50 | """Reply to a message.""" 51 | with open(FIFO, "w") as f: 52 | for line in text.splitlines(): 53 | if not line.strip(): 54 | continue 55 | line = f"<{msg.nick}> {ansi2irc(line)}" 56 | f.write(f"[[{msg.channel}]] {line}\n") 57 | 58 | 59 | def paste(text): 60 | """Paste text to ix.io.""" 61 | info(f"Pasting {text=}") 62 | try: 63 | url = "http://ix.io" 64 | payload = {'f:1=<-': text} 65 | response = requests.request("POST", url, data=payload) 66 | return response.text 67 | except Exception as e: 68 | info(f"Error {e=}") 69 | return "Failed to paste" 70 | 71 | 72 | def read_paste(url): 73 | """Read text from ix.io.""" 74 | response = requests.request("GET", url) 75 | return response.text 76 | 77 | 78 | def run_command(msg: Message, text: str): 79 | 80 | def _run_command(msg: Message, text: str): 81 | def __run_command(msg: Message, text: str): 82 | global user_repls, user_history 83 | user = msg.nick 84 | if user not in user_repls: 85 | info(f"Creating new repl for {user}") 86 | user_repls[user] = replwrap.REPLWrapper( 87 | COMMAND, ">>> ", prompt_change=None) 88 | reply(msg, user_repls[user].run_command(text, timeout=10)) 89 | 90 | if user not in user_history: 91 | user_history[user] = [] 92 | user_history[user].append(text) 93 | 94 | t = threading.Thread(target=__run_command, 95 | args=(msg, text), daemon=True) 96 | t.start() 97 | t.join(10) 98 | if t.is_alive(): 99 | gdscripttop: replwrap.REPLWrapper = user_repls[msg.nick] 100 | gdscripttop.child.kill(signal.SIGINT) 101 | user_repls.pop(msg.nick, None) 102 | reply(msg, "Command timed out. I Cleared your environment") 103 | 104 | threading.Thread(target=_run_command, args=(msg, text)).start() 105 | 106 | 107 | @utils.arg_command("clear", "Clear environment") 108 | async def clear(bot: IrcBot, match: re.Match, message: Message): 109 | global user_repls 110 | user_repls.pop(message.nick, None) 111 | user_history.pop(message.nick, None) 112 | reply(message, "Environment cleared") 113 | 114 | 115 | @utils.regex_cmd_with_messsage(f"^{PREFIX} (.+)$") 116 | async def run(bot: IrcBot, match: re.Match, message: Message): 117 | text = match.group(1).strip() 118 | run_command(message, text) 119 | 120 | 121 | @utils.arg_command("paste", "Pastes your environment code") 122 | async def pipaste(bot: IrcBot, args: re.Match, msg: Message): 123 | if msg.nick not in user_repls: 124 | reply(msg, "You don't have an environment") 125 | return 126 | reply(msg, paste("\n".join(user_history[msg.nick]))) 127 | 128 | 129 | @utils.arg_command("read", "Populates your environment code with code from url") 130 | async def readurl(bot: IrcBot, args: re.Match, msg: Message): 131 | if not args[1]: 132 | reply(msg, "Please provide a url") 133 | return 134 | try: 135 | run_command(msg, read_paste(args[1])) 136 | except Exception as e: 137 | reply(msg, "Failed to read paste: " + str(e)) 138 | reply(msg, "Code has been read and sent!") 139 | 140 | 141 | async def onConnect(bot: IrcBot): 142 | for channel in CHANNELS: 143 | await bot.join(channel) 144 | 145 | async def message_handler(text): 146 | for line in text.splitlines(): 147 | match = re.match(r"^\[\[([^\]]+)\]\] (.*)$", line) 148 | if match: 149 | channel, text = match.groups() 150 | await bot.send_message(text, channel) 151 | 152 | async def update_loop(): 153 | """Update cache to eliminate invalid keys""" 154 | global user_repls, user_history 155 | while True: 156 | user_repls.pop(None, None) 157 | user_history.pop(None, None) 158 | await trio.sleep(3) 159 | 160 | async with trio.open_nursery() as nursery: 161 | nursery.start_soon(listen_loop, FIFO, message_handler) 162 | nursery.start_soon(update_loop) 163 | 164 | if __name__ == "__main__": 165 | print("DOCKER COMMAND:", DOCKER_COMMAND) 166 | print("REPL COMMAND:", COMMAND) 167 | bot = IrcBot(SERVER, PORT, NICK, use_ssl=SSL) 168 | bot.runWithCallback(onConnect) 169 | -------------------------------------------------------------------------------- /irc_bot/config.py.example: -------------------------------------------------------------------------------- 1 | SERVER="irc.dot.org.es" 2 | PORT=6697 3 | DOCKER_IMAGE="docker_godot" 4 | SSL=True 5 | NICK="_gdbot" 6 | CHANNELS=["#bots"] 7 | PREFIX=": " 8 | DOCKER_COMMAND = f"docker run --rm -v %s:/home/godot/ -p 127.0.0.1:9080:9080 {DOCKER_IMAGE}" 9 | -------------------------------------------------------------------------------- /irc_bot/message_server.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # ____ ___ ____ ________ ____ ____ ______ 3 | # / __ \/ | / __ \/ _/ __ \ / __ )/ __ \/_ __/ 4 | # / /_/ / /| | / / / // // / / / / __ / / / / / / 5 | # / _, _/ ___ |/ /_/ // // /_/ / / /_/ / /_/ / / / 6 | # /_/ |_/_/ |_/_____/___/\____/ /_____/\____/ /_/ 7 | # 8 | # 9 | # Matheus Fillipe 18/05/2022 10 | # MIT License 11 | ################################################################################ 12 | 13 | 14 | import asyncio 15 | import os 16 | import stat 17 | import atexit 18 | from typing import Callable 19 | 20 | import trio 21 | 22 | loop = True 23 | FIFO = "/tmp/gdrepl-bot.fifo" 24 | 25 | def stop_loop(): 26 | global loop, FIFO 27 | loop = False 28 | with open(FIFO, "w") as f: 29 | f.write(".") 30 | os.remove(FIFO) 31 | 32 | atexit.register(stop_loop) 33 | 34 | async def listen_loop(fifo_path: str, handler: Callable): 35 | global loop, FIFO 36 | if os.path.exists(fifo_path): 37 | os.remove(fifo_path) 38 | 39 | os.mkfifo(fifo_path) 40 | os.chmod(fifo_path, stat.S_IRWXO | stat.S_IRWXU | stat.S_IRWXG) 41 | FIFO = fifo_path 42 | print("Message Relay listening at fifo: " + fifo_path) 43 | while loop: 44 | async with await trio.open_file(fifo_path) as fifo: 45 | async for line in fifo: 46 | line = line.strip() 47 | if asyncio.iscoroutinefunction(handler): 48 | await handler(line) 49 | else: 50 | handler(line) 51 | -------------------------------------------------------------------------------- /irc_bot/requirements.txt: -------------------------------------------------------------------------------- 1 | re-ircbot==1.6.0 2 | pyexpect==1.0.21 3 | gdrepl 4 | requests==2.13.0 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pexpect==4.8.0 2 | ptyprocess==0.7.0 3 | prompt-toolkit==3.0.30 4 | click==8.1.3 5 | click-default-group==1.2.2 6 | websocket-client==1.3.3 7 | Pygments==2.12.0 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | import setuptools 5 | 6 | VERSION = "v0.0.1", 7 | BRANCH = "master" 8 | 9 | with open("README.md", "r", encoding="utf-8") as fh: 10 | long_description = fh.read() 11 | 12 | def requirements(): 13 | """Build the requirements list for this project.""" 14 | requirements_list = [] 15 | 16 | with open('requirements.txt') as requirements: 17 | for install in requirements: 18 | requirements_list.append(install.strip()) 19 | return requirements_list 20 | 21 | def exec(cmd): 22 | """Execute a command and return the output.""" 23 | return subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True).decode().strip() 24 | 25 | def git_version_tag(): 26 | """Get the current git version tag.""" 27 | try: 28 | branch = exec("git rev-parse --abbrev-ref HEAD") 29 | version = re.match(r"^v[0-9]+(\.[0-9]+)*$", exec("git describe --tags --abbrev=0")) 30 | except subprocess.CalledProcessError: 31 | branch = BRANCH 32 | version = VERSION 33 | if branch == BRANCH and version: 34 | return version[0][1:] 35 | else: 36 | return VERSION 37 | 38 | requirements = requirements() 39 | 40 | VERSION = git_version_tag() 41 | print(f"BUILDING Version: {VERSION}") 42 | 43 | setuptools.setup( 44 | name="gdrepl", 45 | version=VERSION, 46 | author="Matheus Fillipe", 47 | author_email="mattf@tilde.club", 48 | description="Proof of concept repl for godot's gdscript", 49 | long_description=long_description, 50 | long_description_content_type="text/markdown", 51 | url="https://github.com/matheusfillipe/GDScript-REPL", 52 | py_modules=["gdrepl"], 53 | entry_points={ 54 | 'console_scripts': [ 55 | 'gdrepl = gdrepl.main:cli', 56 | ], 57 | }, 58 | packages=setuptools.find_packages(), 59 | include_package_data=True, 60 | install_requires=requirements, 61 | classifiers=[ 62 | "Development Status :: 5 - Production/Stable", 63 | "Programming Language :: Python :: 3", 64 | "License :: OSI Approved :: MIT License", 65 | "Operating System :: OS Independent", 66 | ], 67 | python_requires=">=3.6", 68 | ) 69 | --------------------------------------------------------------------------------