diff --git a/chatbot/v1/chatbot.py b/chatbot/v1/chatbot.py index 10058e5..898197f 100755 --- a/chatbot/v1/chatbot.py +++ b/chatbot/v1/chatbot.py @@ -46,6 +46,8 @@ from threading import Lock +from minescript import echo, BlockPos + try: import openai_api_key except ImportError: @@ -59,14 +61,14 @@ echo("https://beta.openai.com/account/api-keys") sys.exit(1) -from minescript import echo, BlockPos + from typing import Any, List, Set, Dict, Tuple, Optional, Callable OPENAI_API_URL = "https://api.openai.com/v1/completions" OPENAI_API_HEADERS = { "Authorization": f"Bearer {openai_api_key.SECRET_KEY}" } -def ask_chatgpt(prompt: str, model: str = "text-davinci-003", max_tokens: int = 150) -> str: +def ask_chatgpt(prompt: str, model: str = "gpt-4o-mini", max_tokens: int = 150) -> str: data = { "model": model, "prompt": prompt, @@ -113,12 +115,12 @@ def query(question: str, debug: bool = False) -> str: "me: This is tab-delimited tabular data representing Minecraft entity's name, " + "type, health, current activity, block they're on, and position:\n") - world_props = minescript.world_properties() + world_props = minescript.world_info() entities = minescript.entities(nbt=True) my_pos = None for e in entities: - if "local" in e: - my_pos = e["position"] + if "local" in vars(e): + my_pos = e.position break if not my_pos: @@ -126,16 +128,16 @@ def query(question: str, debug: bool = False) -> str: # Assign "dsqr" to each entity: distance squared to local player. for e in entities: - e["dsqr"] = distance_squared(my_pos, e["position"]) + e.dsqr = distance_squared(my_pos, e.position) # Truncate the list of entities at the 50 closest to the local player. - entities.sort(key=lambda e: e["dsqr"]) + entities.sort(key=lambda e: e.dsqr) entities = entities[:50] # Get the block types for the 3 blocks at and immediately below each entity. positions: List[BlockPos] = [None] * (3 * len(entities)) for i, e in enumerate(entities): - positions[3 * i] = block_pos(e["position"]) + positions[3 * i] = block_pos(e.position) positions[3 * i + 1] = block_pos_minus_1y(positions[3 * i]) positions[3 * i + 2] = block_pos_minus_1y(positions[3 * i + 1]) blocks: List[str] = [simple_block_name(b) for b in minescript.getblocklist(positions)] @@ -144,19 +146,19 @@ def query(question: str, debug: bool = False) -> str: my_targeting = None for i, entity in enumerate(entities): - me: bool = entity.get("local") or False - snbt = entity.get("nbt") + me: bool = entity.local or False + snbt = entity.nbt nbt = lib_nbt.parse_snbt(snbt) if snbt else {} - name = entity["name"] - #name = json.loads(nbt.get("CustomName", "{}")).get("text") or entity["name"] + name = entity.name + #name = json.loads(nbt.get("CustomName", "{}")).get("text") or entity.name if me: my_name = name - health = entity.get("health") or "none" + health = entity.health or "none" if type(health) is float: health = str(health).rstrip(".0") - type_ = entity["type"].split(".")[-1].replace("_", " ") - pos = [round(p) for p in entity["position"]] - velocity = entity["velocity"] + type_ = entity.type.split(".")[-1].replace("_", " ") + pos = [round(p) for p in entity.position] + velocity = entity.velocity if velocity[1] < 0 and velocity[1] > -0.08: #echo(f'(entity["{name}"].velocity[1] = {velocity[1]} -> 0)') velocity[1] = 0 # ignore gravitational effect on y velocity @@ -193,7 +195,11 @@ def query(question: str, debug: bool = False) -> str: if me: target = minescript.player_get_targeted_block(500) if target: - _, distance, face, block = target + # _, distance, face, block = target + distance = target.distance + face = target.side + block = target.type + distance = round(distance * 10) / 10 if face == "up": face = "top" @@ -210,11 +216,11 @@ def query(question: str, debug: bool = False) -> str: if my_name: context += f"me: My name is {my_name} and I'm {my_targeting}." - spawn = world_props["spawn"] + spawn = world_props.spawn weather = "clear" - if world_props["raining"]: + if world_props.raining: weather = "raining" - elif world_props["thundering"]: + elif world_props.thundering: weather = "thundering" def ticks_to_time(ticks: int) -> str: @@ -233,7 +239,7 @@ def time_until_sunrise(ticks: int) -> str: def time_until_sunset(ticks: int) -> str: return ticks_to_time((12000 - ticks) % 24000) + " until sunset" - day_ticks = world_props["day_ticks"] % 24000 + day_ticks = world_props.day_ticks % 24000 sun_times = f"{time_until_sunrise(day_ticks)}, {time_until_sunset(day_ticks)}" if day_ticks < 5000: day_time = f"morning" diff --git a/chatbot/v2/README.md b/chatbot/v2/README.md new file mode 100644 index 0000000..da2a8df --- /dev/null +++ b/chatbot/v2/README.md @@ -0,0 +1,51 @@ +## `chatbot v2` + +Get responses from an AI chatbot that's aware of your Minecraft surroundings. + +  + +**Requirements** + + Minescript v4.0 or higher + [lib_nbt](https://minescript.net/sdm_downloads/lib_nbt) v1 or higher + [OpenAI API key](https://beta.openai.com/account/api-keys) (run `chatbot` and follow instructions for setting up your key) + +  + +**Usage** + +Prompt chatbot to get a single response and exit: + +``` +\chatbot PROMPT +``` + +  + +Run chatbot in "interactive mode" in the background and have it respond to messages that match the regular expression PATTERN, with options to ignore upper/lower case and give the chatbot a name: + +``` +\chatbot -i PATTERN [ignorecase] [name=NAME] +``` + +  + +In interactive mode, chatbot output is prefixed with `>>>` and the bot can be stopped by entering `quitbot` into the chat. + +  + +**Examples** + +Ask chatbot a question and get a single response: + +``` +\chatbot "What am I looking at? And how long until sunrise?" +``` + +  + +Run chatbot interactively, responding to chat messages that include the phrase "bot," with any combination of upper/lower case: + +``` +\chatbot -i ".*\bbot,\s" ignorecase +``` diff --git a/chatbot/v2/chatbot.py b/chatbot/v2/chatbot.py new file mode 100755 index 0000000..de56c74 --- /dev/null +++ b/chatbot/v2/chatbot.py @@ -0,0 +1,358 @@ +# SPDX-FileCopyrightText: © 2023 Greg Christiana +# SPDX-License-Identifier: MIT + +r"""chatbot v2 distributed via minescript.net + +Requires: + minescript v4.0 + lib_nbt v1 + +Usage: + Prompt chatbot to get a single response and exit: + + \chatbot PROMPT + + Run chatbot in "interactive mode" in the background and + have it respond to messages that match the regular + expression PATTERN, with options to ignore upper/lower + case and give the chatbot a name: + + \chatbot -i PATTERN [ignorecase] [name=NAME] + + In interactive mode, chatbot output is prefixed with `>>>` + and the bot can be stopped by entering `quitbot` into the + chat. + +Examples: + Ask chatbot a question and get a single response: + + \chatbot "Which entities are approaching me?" + + Run chatbot interactively, responding to chat messages + that include the phrase "bot," with any combination of + upper/lower case: + + \chatbot -i ".*\bbot,\s" ignorecase +""" + +import json +import lib_nbt +import minescript +import os +import requests +import re +import sys +import time + +from threading import Lock + +from minescript import echo, BlockPos + +try: + import openai_api_key +except ImportError: + echo("OpenAI API key is missing.") + echo("Create the file `openai_api_key.py` in the `minescript`") + echo("directory and add a global variable named `SECRET_KEY`:") + echo("") + echo('SECRET_KEY = "sk-..."') + echo("") + echo("Set global variable to an API key that you create at:") + echo("https://beta.openai.com/account/api-keys") + sys.exit(1) + + +from typing import Any, List, Set, Dict, Tuple, Optional, Callable + +OPENAI_API_URL = "https://api.openai.com/v1/completions" +OPENAI_API_HEADERS = { "Authorization": f"Bearer {openai_api_key.SECRET_KEY}" } + + +def ask_chatgpt(prompt: str, model: str = "gpt-4o-mini", max_tokens: int = 150) -> str: + data = { + "model": model, + "prompt": prompt, + "max_tokens": max_tokens + } + + response = requests.post(OPENAI_API_URL, headers=OPENAI_API_HEADERS, json=data) + if response.status_code != 200: + raise Exception(f"Response code not ok ({response.status_code}): {response.text}") + + choices = response.json().get("choices") + if choices is None: + raise Exception(f"No `choices` field in response: {response.text}") + + answer = choices[0]["text"].strip() + return answer + + +def simple_block_name(block: str) -> str: + return block.replace("minecraft:", "").replace("_", " ").split("[")[0] + + +def block_pos(pos: Tuple[float, float, float]) -> BlockPos: + p = [0] * 3 + for i in range(3): + if pos[i] < 0: + p[i] = int(pos[i]) - 1 + else: + p[i] = int(pos[i]) + p[1] -= 1 + return tuple(p) + + +def block_pos_minus_1y(pos: BlockPos) -> BlockPos: + return (pos[0], pos[1] - 1, pos[2]) + + +def distance_squared(pos1: BlockPos, pos2: BlockPos) -> float: + return (pos1[0] - pos2[0]) ** 2 + (pos1[1] - pos2[1]) ** 2 + (pos1[2] - pos2[2]) ** 2 + + +def query(question: str, debug: bool = False) -> str: + context = ( + "me: This is tab-delimited tabular data representing Minecraft entity's name, " + + "type, health, current activity, block they're on, and position:\n") + + world_props = minescript.world_info() + entities = minescript.entities(nbt=True) + my_pos = None + for e in entities: + if "local" in vars(e): + my_pos = e.position + break + + if not my_pos: + return f"Cannot find local player in entities: {entities}" + + # Assign "dsqr" to each entity: distance squared to local player. + for e in entities: + e.dsqr = distance_squared(my_pos, e.position) + + # Truncate the list of entities at the 50 closest to the local player. + entities.sort(key=lambda e: e.dsqr) + entities = entities[:50] + + # Get the block types for the 3 blocks at and immediately below each entity. + positions: List[BlockPos] = [None] * (3 * len(entities)) + for i, e in enumerate(entities): + positions[3 * i] = block_pos(e.position) + positions[3 * i + 1] = block_pos_minus_1y(positions[3 * i]) + positions[3 * i + 2] = block_pos_minus_1y(positions[3 * i + 1]) + blocks: List[str] = [simple_block_name(b) for b in minescript.getblocklist(positions)] + + my_name = None + my_targeting = None + + for i, entity in enumerate(entities): + me: bool = entity.local or False + snbt = entity.nbt + nbt = lib_nbt.parse_snbt(snbt) if snbt else {} + name = entity.name + #name = json.loads(nbt.get("CustomName", "{}")).get("text") or entity.name + if me: + my_name = name + health = entity.health or "none" + if type(health) is float: + health = str(health).rstrip(".0") + type_ = entity.type.split(".")[-1].replace("_", " ") + pos = [round(p) for p in entity.position] + velocity = entity.velocity + if velocity[1] < 0 and velocity[1] > -0.08: + #echo(f'(entity["{name}"].velocity[1] = {velocity[1]} -> 0)') + velocity[1] = 0 # ignore gravitational effect on y velocity + on_ground = nbt.get("OnGround") == 1 + sleeping = nbt.get("Sleeping") == 1 + sitting = nbt.get("Sitting") == 1 + falling = nbt.get("FallDistance") not in (None, 0) + variant = nbt.get("variant") + if type(variant) is str: + variant = variant.split(":")[-1].replace("_", " ") + type_ = f"{variant} {type_}" + + if sleeping: + activity = "sleep" + elif sitting: + activity = "sit" + elif on_ground and not sitting: + activity = "stand" + elif falling: + activity = "fall" + else: + activity = "float" + + # Find the first of the 3 vertically stacked blocks for this entity that's not air, if any. + block = "air" + for dy in range(3): + b = blocks[3 * i + dy] + if b != "air": + block = b + break + + context += f"{name}\t{type_}\t{health}\t{activity}\t{block}\t{' '.join([str(p) for p in pos])}\n" + + if me: + target = minescript.player_get_targeted_block(500) + if target: + # _, distance, face, block = target + distance = target.distance + face = target.side + block = target.type + + distance = round(distance * 10) / 10 + if face == "up": + face = "top" + elif face == "down": + face = "bottom" + block = simple_block_name(block) + if not block.endswith(" block"): + block += " block" + my_targeting = f"targeting the {face} face of a {block} {distance} blocks away" + else: + my_targeting = f"targeting the sky" + + context += "\n" + if my_name: + context += f"me: My name is {my_name} and I'm {my_targeting}." + + spawn = world_props.spawn + weather = "clear" + if world_props.raining: + weather = "raining" + elif world_props.thundering: + weather = "thundering" + + def ticks_to_time(ticks: int) -> str: + seconds = ticks // 20 + if seconds < 120: + return f"{seconds} seconds" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes} minutes {seconds % 60} seconds" + hours = minutes // 60 + return f"{hours} hours {minutes % 60} minutes" + + def time_until_sunrise(ticks: int) -> str: + return ticks_to_time((23000 - ticks) % 24000) + " until sunrise" + + def time_until_sunset(ticks: int) -> str: + return ticks_to_time((12000 - ticks) % 24000) + " until sunset" + + day_ticks = world_props.day_ticks % 24000 + sun_times = f"{time_until_sunrise(day_ticks)}, {time_until_sunset(day_ticks)}" + if day_ticks < 5000: + day_time = f"morning" + elif day_ticks < 7000: + day_time = f"around noon" + elif day_ticks < 9000: + day_time = f"afternoon" + elif day_ticks < 12000: + day_time = f"late afternoon" + elif day_ticks < 13000: + day_time = f"sunset" + elif day_ticks < 22000: + day_time = f"night" + else: + day_time = f"sunrise" + + context += f"\nme: World properties: spawn location is {spawn[0]} {spawn[1]} {spawn[2]}, " + context += f"weather is {weather}, time is {day_time}, " + context += f"{time_until_sunrise(day_ticks)}, {time_until_sunset(day_ticks)}." + + context += f"\nme: {question}\n\nyou:" + if debug: + echo(context) + answer = ask_chatgpt(context) + return answer + + +interactive_bot_lock = Lock() +quit_interactive_bot = False +interactive_bot_trigger_re = None # pattern in chat messags that trigger the bot +interactive_bot_name = None +message_queue = [] + +def on_chat_received(message): + global quit_interactive_bot + global interactive_bot_name + global interactive_bot_trigger_re + + if quit_interactive_bot: + return + if type(message) == str: + if ">>>" in message: + return # Assume these are messages sent by chatbot itself. + + with interactive_bot_lock: + if "quitbot" in message: + quit_interactive_bot = True + elif interactive_bot_trigger_re.match(message): + if interactive_bot_name is not None: + message_queue.append(f'Your name is "{interactive_bot_name}". {message}') + else: + message_queue.append(message) + + +def run_interactive_bot_loop(trigger_pattern: str, re_flags): + global quit_interactive_bot + global interactive_bot_trigger_re + global message_queue + + minescript.log(f'chatbot trigger pattern: "{trigger_pattern}"') + interactive_bot_trigger_re = re.compile(trigger_pattern, flags=re_flags) + minescript.register_chat_message_listener(on_chat_received) + + echo(f">>> {interactive_bot_name or 'chatbot'} is listening. Quit by typing `quitbot`.") + + should_quit = False + while not should_quit: + time.sleep(0.5) + + message = None + with interactive_bot_lock: + should_quit = quit_interactive_bot + if message_queue: + message = message_queue.pop(0) + + if message: + echo(f">>> ({interactive_bot_name or 'chatbot'} is answering...)") + echo(f">>> {query(message.strip())}") + + minescript.unregister_chat_message_listener() + time.sleep(0.5) + + +if __name__ == "__main__": + if len(sys.argv) == 1: + import help + import sys + docstr = help.ReadDocString("chatbot.py") + if docstr: + print(docstr, file=sys.stderr) + sys.exit(0) + sys.exit(1) + + if sys.argv[1] == "-i": + re_flags = 0 + for arg in sys.argv[3:]: + if arg == "ignorecase": + re_flags = re.IGNORECASE + elif arg.startswith("name="): + interactive_bot_name = arg.split("=", 1)[1] + else: + echo(f"Unexpected arg: {arg}") + sys.exit(1) + run_interactive_bot_loop(sys.argv[2], re_flags) + else: + answer = query(sys.argv[1], debug=len(sys.argv) > 2).strip() + if answer: + if answer.startswith("/") or answer.split()[0] in ( + "execute", "summon", "teleport", "tp", "give"): + echo(f"Running command: `{answer}`") + minescript.execute(answer) + else: + echo(answer) + else: + echo("Sorry, ChatGPT is unavailable at the moment. Try again soon.") + diff --git a/eval/v1/README.md b/eval/v1/README.md deleted file mode 100644 index 7359fa3..0000000 --- a/eval/v1/README.md +++ /dev/null @@ -1,47 +0,0 @@ -## `eval v1` - -Executes the given parameter as Python code. - -  - -**Usage** - -``` -\eval -``` - -Executes `` as either a Python expression (code -that can appear on the right-hand side of an assignment, in -which case the value is echoed to the chat screen) or Python -statements (e.g. a `for` loop). - -Functions from minescript.py are available automatically without -qualification. - -Multiple lines of code can be written using escaped newlines -(`\n`). - -  - -**Examples** - -Print information about nearby entities to the chat screen: - -``` -\eval "entities()" -``` -*(note: entities() added in Minescript v2.1)* - -Print the names of nearby entities to the chat screen: - -``` -\eval "for e in entities(): echo(e['name'])" -``` -*(note: entities() added in Minescript v2.1)* - -Import `time` module, sleep 3 seconds, and take a screenshot: - -``` -\eval "import time\ntime.sleep(3)\nscreenshot()" -``` -*(note: screenshot() added in Minescript v2.1)* diff --git a/eval/v1/eval.py b/eval/v1/eval.py deleted file mode 100644 index 6b5e3a9..0000000 --- a/eval/v1/eval.py +++ /dev/null @@ -1,69 +0,0 @@ -# SPDX-FileCopyrightText: © 2022 Greg Christiana -# SPDX-License-Identifier: MIT - -r"""eval v1 distributed via minescript.net - -Usage: - \eval - -Executes as either a Python expression (code -that can appear on the right-hand side of an assignment, in -which case the value is echoed to the chat screen) or Python -statements (e.g. a `for` loop). - -Functions from minescript.py are available automatically without -qualification. - -Multiple lines of code can be written using escaped newlines -(`\n`). - -Examples: - Print information about nearby entities to the chat screen: - \eval "entities()" - (note: entities() added in Minescript v2.1) - - Print the names of nearby entities to the chat screen: - \eval "for e in entities(): echo(e['name'])" - (note: entities() added in Minescript v2.1) - - Import `time` module, sleep 3 seconds, and take a screenshot: - \eval "import time\ntime.sleep(3)\nscreenshot()" - (note: screenshot() added in Minescript v2.1) - -""" - -# `from ... import *` is normally considered poor form because of namespace -# pollution. But it's desirable in this case because it allows single-line -# Python code that's entered in the Minecraft chat screen to omit the module -# prefix for brevity. And brevity is important for this use case. -from minescript import * -from typing import Any -import builtins -import sys - -def run(python_code: str) -> None: - """Executes python_code as an expression or statements. - - Args: - python_code: Python expression or statements (newline-delimited) - """ - # Try to evaluate as an expression. - try: - print(builtins.eval(python_code), file=sys.stderr) - return - except SyntaxError: - pass - - # Fall back to executing as statements. - builtins.exec(python_code) - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print( - f"eval.py: Expected 1 parameter, instead got {len(sys.argv) - 1}: {sys.argv[1:]}", - file=sys.stderr) - print(r"Usage: \eval ", file=sys.stderr) - sys.exit(1) - - run(sys.argv[1]) diff --git a/interpreter/v2/README.md b/interpreter/v2/README.md new file mode 100644 index 0000000..99f0024 --- /dev/null +++ b/interpreter/v2/README.md @@ -0,0 +1,31 @@ +### interpreter module + +A Python REPL interpreter in the Minecraft chat with +Java reflection. + +If a file is found in any of the directories of the config +variable `command_path` from `config.txt` with the filename +`.interpreter_init.py`, that script is loaded during startup +of the interpreter. + +When the interpreter launches, the prompt ">>>" appears +in the Minecraft chat. Enter Python statements or expressions +just as you would in a Python REPL in a terminal. + +Put the interpreter in the background by hitting the escape +key or deleting the ">>>" prompt to enter a Minecraft command, +chat message, or Minescript command. + +Bring the interpreter to the foreground by pressing "i" while +no GUI screens are visible. + +Exit the interpreter by typing "." at the ">>>" prompt. + +*Requires:* +- `minescript v4.0` +- `lib_java v2` + +*Usage:* + + \interpreter + diff --git a/interpreter/v2/interpreter.py b/interpreter/v2/interpreter.py new file mode 100644 index 0000000..b9abc67 --- /dev/null +++ b/interpreter/v2/interpreter.py @@ -0,0 +1,304 @@ +# SPDX-FileCopyrightText: © 2024 Greg Christiana +# SPDX-License-Identifier: MIT + +r"""interpreter v2 distributed via minescript.net + +A Python REPL interpreter in the Minecraft chat with +Java reflection. + +If a file is found in any of the directories of the config +variable `command_path` from `config.txt` with the filename +`.interpreter_init.py`, that script is loaded during startup +of the interpreter. + +When the interpreter launches, the prompt ">>>" appears +in the Minecraft chat. Enter Python statements or expressions +just as you would in a Python REPL in a terminal. + +Put the interpreter in the background by hitting the escape +key or deleting the ">>>" prompt to enter a Minecraft command, +chat message, or Minescript command. + +Bring the interpreter to the foreground by pressing "i" while +no GUI screens are visible. + +Exit the interpreter by typing "." at the ">>>" prompt. + +Requires: + minescript v4.0 + lib_java v2 + +Usage: + \interpreter +""" + +import faulthandler +import signal + +from minescript import ( + EventQueue, + EventType, + KeyEvent, + append_chat_history, + chat_input, + echo_json, + java_array_index, + java_call_method, + java_class, + java_member, + java_release, + java_to_string, + log, + render_loop, + script_loop, + set_chat_input, + show_chat_screen, +) + +from lib_java import ( + AutoReleasePool, + JavaClass, + JavaObject, + get_unobfuscated_member_name, +) + +import minescript_runtime +from minescript_runtime import debug_log + +import ast +import builtins +import os +import re +import sys +import time + +TAB_KEY = 258 +RIGHT_KEY = 262 +LEFT_KEY = 263 +control_modifier = 2 +enter_key = 257 +trigger_key = 73 # `i` key +chat_prefix = ">>> " +last_code_time = 0 + + +if minescript_runtime._is_debug: + faulthandler.register(signal.SIGUSR1, minescript_runtime._debug_log) + + +def is_valid_subexpression(text): + try: + ast.parse(text) + return True + except SyntaxError: + return False + + +def longest_trailing_subexpression(text): + ends_in_dot = text[-1] == "." + if ends_in_dot: + text = text[:-1] + for i in range(len(text)): + # TODO: Skip this iter of loop if prev chat and current char are both str.alnum(), i.e. only + # split subexpression on word boundaries. + suffix = text[i:] + if is_valid_subexpression(suffix): + return suffix + "." if ends_in_dot else suffix + return None + + +def replace_unquoted_dot_class(text): + # `.class` is illegal syntax in Python. So translate unquoted `.class` to `.class_`. + + def replacer(match): + if match.group(1): # Inside quotes + return match.group(0) + else: # Outside quotes on word boundary + return ".class_" + + pattern = r""" + (?P['"]) # Capture a quote + .*? # Match anything within quotes (non-greedy) + (?P=quote) # Match the same quote to close the region + | # OR + \b\.class\b # Match '.class' on word boundary + """ + return re.sub(pattern, replacer, text, flags=re.VERBOSE) + +Member_getName = None + +def get_completions(target, local_vars, partial): + global Member_getName + with script_loop: + if Member_getName is None: + Member_class = java_class("java.lang.reflect.Member") + Member_getName = java_member(Member_class, "getName") + java_release(Member_class) + + completions = set() + if target: + try: + with AutoReleasePool() as auto: + target = builtins.eval(target) + if isinstance(target, JavaObject): + log(f"target -> {target} ({target.__dict__})") + if isinstance(target, JavaClass): + target_class = target.class_ + else: + target_class = target.getClass() + log(f"target class -> {target_class}") + methods = target_class.getMethods() + num_methods = len(methods) + for i in range(num_methods): + log(f"Processing method {i} of {num_methods}") + method = auto(java_array_index(methods.id, i)) + name = java_to_string(auto(java_call_method(method, Member_getName))) + for n in (name, get_unobfuscated_member_name(name)): + if n is not None and n.startswith(partial): + completions.add(n + "(") + del methods + + fields = target_class.getFields() + num_fields = len(fields) + for i in range(num_fields): + log(f"Processing field {i} of {num_fields}") + field = auto(java_array_index(fields.id, i)) + name = java_to_string(auto(java_call_method(field, Member_getName))) + for n in (name, get_unobfuscated_member_name(name)): + if n is not None and n.startswith(partial): + completions.add(n) + del fields + except Exception as e: + echo_json({"text": f"Error: {str(e)}", "color": "red"}) + else: + for var in local_vars: + if var.startswith(partial): + completions.add(var) + return list(completions) + + +def longest_common_prefix(strings): + if not strings: + return "" + longest = strings[0] + for i in range(1, len(strings)): + string = strings[i] + end = min(len(string), len(longest)) + if end < len(longest): + longest = longest[0:end] + for j in range(end): + if string[j] != longest[j]: + longest = longest[0:j] + break + return longest + + +def process_key_event(event: KeyEvent, local_vars): + with render_loop: + if event.key == trigger_key and event.action == 1 and event.screen is None: + show_chat_screen(True, chat_prefix) + elif abs(event.time - last_code_time) < 0.5: + if event.key == enter_key and event.action != 1 and event.screen is None: + show_chat_screen(True, chat_prefix) + elif event.key == TAB_KEY and event.action == 1 and event.screen == "Chat screen": + text, pos = chat_input() + if text.startswith(chat_prefix) and pos == len(text): + suffix = longest_trailing_subexpression(text[len(chat_prefix):]) + if not suffix: + return + last_dot = suffix.rfind(".") + if last_dot == -1: + target = None + partial = suffix + else: + target = suffix[:last_dot] + partial = suffix[last_dot + 1:] + start_time = time.time() + completions = get_completions(target, local_vars, partial) + end_time = time.time() + log(f"Finished completions for `{partial}` in {end_time - start_time} seconds") + color = 0x5ee85e # green for full completion + if len(completions) > 1: + color = 0x5ee8e8 # cyan for partial completion + print(" ", file=sys.stderr) + for c in completions: + print(c, file=sys.stderr) + match = longest_common_prefix(completions) + if match: + text += match[len(partial):] + set_chat_input(text, len(text), color) + elif event.action == 1 and event.screen == "Chat screen" and event.key not in ( + LEFT_KEY, RIGHT_KEY) and chat_input()[0].startswith(chat_prefix): + set_chat_input(color=0xffffff) # white for default text input + + +with EventQueue() as q: + q.register_key_listener() + q.register_outgoing_chat_interceptor(prefix=chat_prefix) + print("Press `i` to return to the interpreter. Type `.` to exit.", file=sys.stderr) + show_chat_screen(True, chat_prefix) + + event = None + message = None + + # Local vars to be excluded from tab completion. + ignore_completions = [ + "minescript_runtime", "MinescriptRuntimeOptions", "event", "message", "python_dirs", "dirname", + "init_filename", "last_code_time", "source_code", "chat_prefix" + ] + + with script_loop: + Minescript = JavaClass("net.minescript.common.Minescript") + + python_dirs = os.environ["MINESCRIPT_COMMAND_PATH"].split(os.pathsep) + for dirname in python_dirs: + init_filename = os.path.join(dirname, ".interpreter_init.py") + if os.path.exists(init_filename): + with open(init_filename, "r") as init_file: + builtins.exec(init_file.read()) + del init_file + print("Loaded .interpreter_init.py", file=sys.stderr) + break + + while True: + try: + debug_log("interpreter.py: Waiting for event queue...") + event = q.get() + debug_log(f"interpreter.py: Got event of type `{event.type}`") + if event.type == EventType.OUTGOING_CHAT_INTERCEPT: + message = event.message + with render_loop: + append_chat_history(message) + if message.startswith(chat_prefix): + last_code_time = event.time + print(message, file=sys.stderr) + source_code = replace_unquoted_dot_class(message[len(chat_prefix):]) + if source_code == ".": + print("Stopping interpreter.", file=sys.stderr) + break + elif source_code: + try: + with render_loop: + print(builtins.eval(source_code), file=sys.stderr) + continue + except SyntaxError: + pass + + # Fall back to executing as statements. + with render_loop: + builtins.exec(source_code) + continue + + elif event.type == EventType.KEY: + process_key_event(event, set(locals().keys()).difference(ignore_completions)) + + except Exception as e: + echo_json({"text": f"Error: {str(e)}", "color": "red"}) + + except: + log("Unknown exception") + echo_json({"text": "Unknown exception", "color": "red"}) + + debug_log("interpreter.py:", "Exited `while` loop") + +debug_log("interpreter.py:", "Exited EventQueue") \ No newline at end of file diff --git a/lib_java/v2/README.md b/lib_java/v2/README.md new file mode 100644 index 0000000..de57863 --- /dev/null +++ b/lib_java/v2/README.md @@ -0,0 +1,278 @@ +### lib_java module + +Library for using Java reflection from Python, wrapping +the low-level Java API script functions (`java_*`). + +*Requires:* +- `minescript v4.0` + +*Example:* + + ``` + from minescript import (echo, version_info) + from lib_java import ( + JavaClass, java_class_map, java_member_map) + + # If using a version of Minecraft with obfuscated + # symbols, populate these dictionaries with the + # appropriate mappings, for example: + mc_class_name = version_info().minecraft_class_name + if mc_class_name == "net.minecraft.class_310": + java_class_map.update({ + "net.minecraft.client.Minecraft": "net.minecraft.class_310", + }) + java_member_map.update({ + "getInstance": "method_1551", + "getFps": "method_47599", + }) + + Minecraft = JavaClass("net.minecraft.client.Minecraft") + minecraft = Minecraft.getInstance() + echo("fps:", minecraft.getFps()) + ``` + +#### AutoReleasePool.\_\_call\_\_ +*Usage:* AutoReleasePool.\_\_call\_\_(ref: JavaHandle) -> JavaHandle + +Track `ref` for auto-release when this pool is deleted or goes out of scope. + +*Returns:* + +- `ref` for convenient wrapping of functions returning a JavaHandle. + + +#### TaskRecorder +Context for recording tasks when interacting with JavaObject fields and methods. + +*Example:* + +``` +from minescript import script_loop, render_loop, run_tasks +from lib_java import JavaClass, TaskRecorder + +task_recorder = TaskRecorder() + +# This assumes symbols aren't obfuscated. If they are, +# update java_class_map and java_member_map from lib_java. +# Recording on the script loop is generally safe and efficient. +with script_loop: + Minecraft = JavaClass("net.minecraft.client.Minecraft") + + # Record tasks for launching the player vertically. + # Within the `with task_recorder:` code block, field and + # method accesses on JavaObject instances are recorded + # rather than executed. + with task_recorder: + minecraft = Minecraft.getInstance() + player = minecraft.player + player.setDeltaMovement(0., 2., 0.) + +# Run the recorded tasks on the render loop since that's where +# interactions with game state generally need to be executed. +with render_loop: + run_tasks(task_recorder.recorded_tasks()) +``` + + +#### TaskRecorder.active +*Usage:* @staticmethod TaskRecorder.active() -> "TaskRecorder" + +Returns the active [`TaskRecorder`](#taskrecorder) for the current thread, or `None`. + +#### TaskRecorder.\_\_bool\_\_ +*Usage:* TaskRecorder.\_\_bool\_\_() + +Always `True` as a convenience for checking [`TaskRecorder.active`](#taskrecorderactive) which may be `None` + +#### Float +Wrapper class for mirroring Java `float` in Python. + +Python `float` maps to Java `double`, and Python doesn't have a built-in single-precision float. + + +#### JavaObject +Python representation of a Java object. + +#### JavaObject.\_\_init\_\_ +*Usage:* JavaObject(target_id: JavaHandle, ref: JavaRef = None) + +Constructs a Python handle to a Java object given a `JavaHandle`. + +#### JavaObject.toString +*Usage:* JavaObject.toString() -> str + +Returns a `str` representation of `this.toString()` from Java. + +#### JavaObject.set_value +*Usage:* JavaObject.set_value(value: Any) + +Sets this JavaObject to reference `value` instead. + +`value` can be any of the following types: +- bool: converted to Java Boolean +- int: converted to Java Integer +- Float: converted to Java Float +- float: converted to Java Double +- str: converted to Java String +- JavaObject: this JavaObject will reference the same Java object as `value` + + +#### JavaObject.\_\_getattr\_\_ +*Usage:* JavaObject.\_\_getattr\_\_(name: str) + +Accesses the field or method named `name`. + +*Args:* + +- `name`: name of a field or method on this JavaObject's class + +*Returns:* + +- If `name` matches a field on this JavaObject's class, then return the + value of that field as a Python primitive or new JavaObject. Otherwise + return a [`JavaBoundMember`](#javaboundmember) equivalent to the Java expression + `this::methodName`. + + +#### JavaObject.\_\_len\_\_ +*Usage:* JavaObject.\_\_len\_\_() -> int + +If this JavaObject represents a Java array, returns the length of the array. + +Raises `TypeError` if this isn't an array. + + +#### JavaObject.\_\_getitem\_\_ +*Usage:* JavaObject.\_\_getitem\_\_(i: int) + +If this JavaObject represents a Java array, returns `array[i]`. + +*Args:* + +- `i`: index into array from which to get an element + +*Returns:* + +- `array[i]` as a Python primitive value or JavaObject. + +*Raises:* + + `TypeError` if this isn't an array. + + +#### JavaBoundMember +Representation of a Java method reference in Python. + +#### JavaBoundMember.\_\_init\_\_ +*Usage:* JavaBoundMember(target_class_id: JavaHandle, target, name: str, ref: JavaRef = None) + +Member that's bound to a target object, representing a field or method. + +*Args:* + +- `target_class_id`: Java object ID of enclosing class for this member +- `target`: either Java object ID of the target through which this member is accessed, or + Task for scheduled execution +- `name`: name of this member + + +#### JavaBoundMember.\_\_call\_\_ +*Usage:* JavaBoundMember.\_\_call\_\_(\*args) + +Calls the bound method with the given `args`. + +*Returns:* + +- A Python primitive (bool, int, float, str) if applicable, otherwise a JavaObject. + + +#### JavaInt +JavaObject subclass for Java Integer. + +#### JavaFloat +JavaObject subclass for Java Float. + +#### JavaString +JavaObject subclass for Java String. + +#### JavaClass +JavaObject subclass for Java class objects. + +#### JavaClass.is_enum +*Usage:* JavaClass.is_enum() + +Returns `True` if this class represents a Java enum type. + +#### JavaClass.\_\_getattr\_\_ +*Usage:* JavaClass.\_\_getattr\_\_(name) + +Accesses the static field or static method named `name` on this Java class. + +*Args:* + +- `name`: name of a static field or static method on this Java class. + +*Returns:* + +- If `name` matches a static field on this Java class, then return the value of that field as + a new JavaObject. Otherwise return a [`JavaBoundMember`](#javaboundmember) equivalent to the Java expression + `ThisClass::staticMethodName`. + + +#### JavaClass.\_\_call\_\_ +*Usage:* JavaClass.\_\_call\_\_(\*args) + +Calls the constructor for this Java class that takes the given `args`, if applicable. + +*Returns:* + +- JavaObject representing the newly constructed Java object. + + +#### callScriptFunction +*Usage:* callScriptFunction(func_name: str, \*args) -> [JavaObject](#javaobject) + +Calls the given Minescript script function. + +*Args:* + +- `func_name`: name of a Minescript script function +- `args`: args to pass to the given script function + +*Returns:* + +- The return value of the given script function as a Python primitive type or JavaObject. + + +#### JavaFuture +Java value that will become available in the future when an async function completes. + +#### JavaFuture.wait +*Usage:* JavaFuture.wait(timeout=None) + +Waits for the async function to complete. + +*Args:* + +- `timeout`: if not `None`, timeout in seconds to wait on the async function to complete + +*Returns:* + +- Python primitive value or JavaObject returned from the async function upon completion. + + +#### callAsyncScriptFunction +*Usage:* callAsyncScriptFunction(func_name: str, \*args) -> [JavaFuture](#javafuture) + +Calls the given Minescript script function asynchronously. + +*Args:* + +- `func_name`: name of a Minescript script function +- `args`: args to pass to the given script function + +*Returns:* + +- [`JavaFuture`](#javafuture) that will hold the return value of the async funcion when complete. + + diff --git a/lib_java/v2/lib_java.py b/lib_java/v2/lib_java.py new file mode 100644 index 0000000..429cd31 --- /dev/null +++ b/lib_java/v2/lib_java.py @@ -0,0 +1,750 @@ +# SPDX-FileCopyrightText: © 2024 Greg Christiana +# SPDX-License-Identifier: MIT + +r"""lib_java v2 distributed via minescript.net + +Library for using Java reflection from Python, wrapping +the low-level Java API script functions (`java_*`). + +Requires: + minescript v4.0 + +Example: + ``` + from minescript import (echo, version_info) + from lib_java import ( + JavaClass, java_class_map, java_member_map) + + # If using a version of Minecraft with obfuscated + # symbols, populate these dictionaries with the + # appropriate mappings, for example: + mc_class_name = version_info().minecraft_class_name + if mc_class_name == "net.minecraft.class_310": + java_class_map.update({ + "net.minecraft.client.Minecraft": "net.minecraft.class_310", + }) + java_member_map.update({ + "getInstance": "method_1551", + "getFps": "method_47599", + }) + + Minecraft = JavaClass("net.minecraft.client.Minecraft") + minecraft = Minecraft.getInstance() + echo("fps:", minecraft.getFps()) + ``` +""" + +from minescript import ( + JavaHandle, + Task, + java_access_field, + java_array_index, + java_array_length, + java_assign, + java_bool, + java_call_method, + java_call_script_function, + java_class, + java_ctor, + java_double, + java_float, + java_int, + java_member, + java_new_instance, + java_release, + java_string, + java_to_string, + log, + script_loop, +) +from minescript_runtime import debug_log +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Set, Tuple +import threading + +# These script functions should be safe to call on any thread: +# TODO(maxuser): Include java_access_field? +for func in ( + java_array_index, java_array_length, + java_class, java_double, java_float, java_int, java_bool, + java_member, java_release, java_string, java_assign): + func.set_required_executor(script_loop) + +# Map from unobfuscated class name to the obfuscated name being used in Java. +java_class_map: Dict[str, str] = {} + +# Map from unobfuscated method or field name to the obfuscated name being used in Java. +java_member_map: Dict[str, str] = {} + +_inverse_java_member_map: Dict[str, str] = None + +def get_unobfuscated_member_name(obfuscated_name: str) -> str: + global _inverse_java_member_map + if _inverse_java_member_map is None or len(_inverse_java_member_map) != len(java_member_map): + _inverse_java_member_map = {v: k for k, v in java_member_map.items()} + return _inverse_java_member_map.get(obfuscated_name) + + +class AutoReleasePool: + def __init__(self): + self.refs = [] + + def __call__(self, ref: JavaHandle) -> JavaHandle: + """Track `ref` for auto-release when this pool is deleted or goes out of scope. + + Returns: + `ref` for convenient wrapping of functions returning a JavaHandle. + """ + self.refs.append(ref) + return ref + + def release_all(self): + if self.refs: + java_release(*self.refs) + self.refs.clear() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.release_all() + + def __del__(self): + self.release_all() + +class ClassInfo: + def __init__(self, id: JavaHandle): + self.id: JavaHandle = id + self._class_name = None + self._field_names: Set[str] = None + + def class_name(self) -> str: + if self._class_name is None: + with AutoReleasePool() as auto: + jclass_name = auto(java_call_method(self.id, Class_getName_id)) + self._class_name = java_to_string(jclass_name) + return self._class_name + + def field_names(self) -> Set[str]: + if self._field_names is None: + field_names = set() + with script_loop: + with AutoReleasePool() as auto: + jfields_array = auto(java_call_method(self.id, Class_getFields_id)) + for i in range(java_array_length(jfields_array)): + jfield = auto(java_array_index(jfields_array, i)) + jfield_name = auto(java_call_method(jfield, Field_getName_id)) + field_name = java_to_string(jfield_name) + field_names.add(field_name) + unobfuscated_name = get_unobfuscated_member_name(field_name) + if unobfuscated_name is not None: + field_names.add(unobfuscated_name) + self._field_names = field_names + return self._field_names + + +_class_info: Dict[int, ClassInfo] = {} + +def _get_class_info(class_id: JavaHandle) -> ClassInfo: + class_info = _class_info.get(class_id) + if class_info is None: + class_info = ClassInfo(class_id) + _class_info[class_id] = class_info + return class_info + + +def find_java_class(name: str): + with script_loop: + return java_class(java_class_map.get(name, name)) + +def find_java_member(clss, name: str): + with script_loop: + return java_member(clss, java_member_map.get(name, name)) + +_null_id = 0 + +_threaded_tasks = threading.local() # Has attr "recorder" when recording. + +class TaskRecorder: + """Context for recording tasks when interacting with JavaObject fields and methods. + + Example: + ``` + from minescript import script_loop, render_loop, run_tasks + from lib_java import JavaClass, TaskRecorder + + task_recorder = TaskRecorder() + + # This assumes symbols aren't obfuscated. If they are, + # update java_class_map and java_member_map from lib_java. + # Recording on the script loop is generally safe and efficient. + with script_loop: + Minecraft = JavaClass("net.minecraft.client.Minecraft") + + # Record tasks for launching the player vertically. + # Within the `with task_recorder:` code block, field and + # method accesses on JavaObject instances are recorded + # rather than executed. + with task_recorder: + minecraft = Minecraft.getInstance() + player = minecraft.player + player.setDeltaMovement(0., 2., 0.) + + # Run the recorded tasks on the render loop since that's where + # interactions with game state generally need to be executed. + with render_loop: + run_tasks(task_recorder.recorded_tasks()) + ``` + """ + + def __init__(self): + self._tasks: List[Task] = [] + self._java_ref_tasks: List[Task] = [] + + @staticmethod + def active() -> "TaskRecorder": + """Returns the active `TaskRecorder` for the current thread, or `None`.""" + if hasattr(_threaded_tasks, "recorder"): + return _threaded_tasks.recorder + else: + return None + + def __bool__(self): + """Always `True` as a convenience for checking `TaskRecorder.active` which may be `None`""" + return True + + def __enter__(self): + if hasattr(_threaded_tasks, "recorder"): + raise RuntimeError( + "Cannot record tasks while another TaskRecorder is already recording on this thread.") + _threaded_tasks.recorder = self + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if not hasattr(_threaded_tasks, "recorder"): + raise RuntimeError("No TaskRecorder currently recording on this thread.") + if _threaded_tasks.recorder is not self: + raise RuntimeError("A conflicting TaskRecorder was recording on this thread.") + del _threaded_tasks.recorder + + def append_task(self, task: Task): + self._tasks.append(task) + + def append_java_task(self, task: Task): + self._tasks.append(task) + self._java_ref_tasks.append(task) + + def recorded_tasks(self): + return self._tasks + [java_release.as_task(*self._java_ref_tasks)] + + +with script_loop: + Object_id = find_java_class("java.lang.Object") + Object_getClass_id = find_java_member(Object_id, "getClass") + + Objects_id = find_java_class("java.util.Objects") + Objects_isNull_id = find_java_member(Objects_id, "isNull") + + Class_id = find_java_class("java.lang.Class") + Class_getName_id = find_java_member(Class_id, "getName") + Class_getField_id = find_java_member(Class_id, "getField") + Class_getFields_id = find_java_member(Class_id, "getFields") + Class_getMethods_id = find_java_member(Class_id, "getMethods") + Class_isEnum_id = find_java_member(Class_id, "isEnum") + + Field_id = find_java_class("java.lang.reflect.Field") + Field_getType_id = find_java_member(Field_id, "getType") + Field_getName_id = find_java_member(Field_id, "getName") + + Method_id = find_java_class("java.lang.reflect.Method") + Method_getName_id = find_java_member(Method_id, "getName") + Method_getReturnType_id = find_java_member(Method_id, "getReturnType") + Method_getParameterCount_id = find_java_member(Method_id, "getParameterCount") + + Boolean_id = find_java_class("java.lang.Boolean") + Integer_id = find_java_class("java.lang.Integer") + Float_id = find_java_class("java.lang.Float") + Double_id = find_java_class("java.lang.Double") + +@dataclass +class Float: + """Wrapper class for mirroring Java `float` in Python. + + Python `float` maps to Java `double`, and Python doesn't have a built-in single-precision float. + """ + + value: float + + def __str__(self): + return f"{self.value}f" + +def to_java_type(value): + if value is None: + return _null_id + + t = type(value) + if t is bool: + return java_bool(value) + elif t is int: + return java_int(value) + elif t is Float: + return java_float(value.value) + elif t is float: + return java_double(value) + elif t is str: + return java_string(value) + elif isinstance(value, RecordedTask): + return value.task + elif isinstance(value, JavaObject): + return value.id + else: + raise ValueError(f"Python type {type(value)} not convertible to Java: {value}") + +def from_java_type(java_id: JavaHandle): + # TODO(maxuser): Run most of these calls in script_loop, but not java_to_string when uncertain + # that it's a primitive type. + with AutoReleasePool() as auto: + if java_id == _null_id or \ + java_to_string(auto(java_call_method(_null_id, Objects_isNull_id, java_id))) == "true": + return None + + java_type = java_to_string( + auto(java_call_method(auto(java_call_method(java_id, Object_getClass_id)), Class_getName_id))) + if java_type == "java.lang.Boolean": + return java_to_string(java_id) == "true" + elif java_type == "java.lang.Integer": + return int(java_to_string(java_id)) + elif java_type == "java.lang.Float": + return Float(float(java_to_string(java_id))) + elif java_type == "java.lang.Double": + return float(java_to_string(java_id)) + elif java_type == "java.lang.String": + return java_to_string(java_id) + else: + return JavaObject(java_id) + +def _promote_primitive_types(type_id: JavaHandle) -> JavaHandle: + type_name = _get_class_info(type_id).class_name() + if type_name == "boolean": + return Boolean_id + if type_name == "int": + return Integer_id + if type_name == "float": + return Float_id + if type_name == "double": + return Double_id + return type_id + +class JavaRef: + def __init__(self, id: JavaHandle): + self.id = id + self.count = 1 + + def increment(self): + self.count += 1 + + def decrement(self): + self.count -= 1 + if self.count <= 0: + debug_log(f"del JavaRef {self.id}") + java_release(self.id) + +class JavaObject: + """Python representation of a Java object.""" + + def __init__(self, target_id: JavaHandle, ref: JavaRef = None): + """Constructs a Python handle to a Java object given a `JavaHandle`. """ + self.id = target_id + if ref is None: + self.ref = JavaRef(target_id) + else: + ref.increment() + self.ref = ref + self._class_id = None + self.is_array = None + + def __repr__(self): + class_name = _get_class_info(self.get_class_id()).class_name() + return f'JavaObject("{class_name}")' + + def toString(self) -> str: + """Returns a `str` representation of `this.toString()` from Java.""" + return java_to_string(self.id) + + def get_class_id(self): + if self._class_id is None: + self._class_id = java_call_method(self.id, Object_getClass_id) + return self._class_id + + def set_value(self, value: Any): + """Sets this JavaObject to reference `value` instead. + + `value` can be any of the following types: + - bool: converted to Java Boolean + - int: converted to Java Integer + - Float: converted to Java Float + - float: converted to Java Double + - str: converted to Java String + - JavaObject: this JavaObject will reference the same Java object as `value` + """ + if value is None: + java_assign(self.id, 0) + return + + def set_java_primitive(java_primitive_ctor: Callable[[Any], int], value: Any): + jvalue = java_primitive_ctor(value) + java_assign(self.id, jvalue) + java_release(jvalue) + + t = type(value) + if t is bool: + set_java_primitive(java_bool, value) + elif t is int: + set_java_primitive(java_int, value) + elif t is Float: + set_java_primitive(java_float, value.value) + elif t is float: + set_java_primitive(java_double, value) + elif t is str: + set_java_primitive(java_string, value) + elif isinstance(value, JavaObject): + java_assign(self.value.id, value.id) + else: + raise ValueError(f"Python type {type(value)} not convertible to Java: {value}") + + def __str__(self): + return java_to_string(self.id) + + def __del__(self): + if self.ref is not None: + self.ref.decrement() + + def __getattr__(self, name: str): + """Accesses the field or method named `name`. + + Args: + name: name of a field or method on this JavaObject's class + + Returns: + If `name` matches a field on this JavaObject's class, then return the + value of that field as a Python primitive or new JavaObject. Otherwise + return a `JavaBoundMember` equivalent to the Java expression + `this::methodName`. + """ + binding = JavaBoundMember(self.get_class_id(), self.id, name, ref=self.ref) + + task_recorder = TaskRecorder.active() + if task_recorder: + log(f"Recording JavaObject get `{name}`") + log(f"Field names for `{_get_class_info(self.get_class_id()).class_name()}`: {_get_class_info(self.get_class_id()).field_names()}") + if name not in _get_class_info(self.get_class_id()).field_names(): + return binding + try: + field_id = java_call_method(self.get_class_id(), Class_getField_id, java_string(name)) + field_type_id = _promote_primitive_types(java_call_method(field_id, Field_getType_id)) + task = RecordedTask( + field_type_id, + java_access_field.as_task(self.id, binding.member_id), + f"field `{name}`") + task_recorder.append_java_task(task.task) + return task + except Exception as e: + debug_log(f"lib_java.py: caught exception accessing field `{name}`: {e}") + return binding + + if name not in _get_class_info(self.get_class_id()).field_names(): + return binding + try: + field = java_access_field(self.id, binding.member_id) + return from_java_type(field) + except Exception as e: + debug_log(f"lib_java.py: caught exception accessing field `{name}`: {e}") + return binding + + def _is_array(self): + if self.is_array is None: + self_class = java_call_method(self.id, Object_getClass_id) + classname = java_to_string(java_call_method(self_class, Class_getName_id)) + self.is_array = classname.startswith("[") + return self.is_array + + def __len__(self) -> int: + """If this JavaObject represents a Java array, returns the length of the array. + + Raises `TypeError` if this isn't an array. + """ + if self._is_array(): + return java_array_length(self.id) + else: + raise TypeError(f"object {self.id} has no len()") + + def __getitem__(self, i: int): + """If this JavaObject represents a Java array, returns `array[i]`. + + Args: + i: index into array from which to get an element + + Returns: + `array[i]` as a Python primitive value or JavaObject. + + Raises: + `TypeError` if this isn't an array. + """ + if self._is_array(): + return from_java_type(java_array_index(self.id, i)) + else: + raise TypeError(f"object {self.id} is not subscriptable") + + +class JavaBoundMember: + """Representation of a Java method reference in Python.""" + def __init__(self, target_class_id: JavaHandle, target, name: str, ref: JavaRef = None): + """Member that's bound to a target object, representing a field or method. + + Args: + target_class_id: Java object ID of enclosing class for this member + target: either Java object ID of the target through which this member is accessed, or + Task for scheduled execution + name: name of this member + """ + self.ref = ref + if ref is not None: + ref.increment() + + self.target_class_id = target_class_id + self.target = target + self.member_name = java_member_map.get(name, name) + self.member_id = find_java_member(target_class_id, name) + + def __del__(self): + if self.ref is not None: + debug_log(f"Deleting bound member {self.member_name}") + self.ref.decrement() + + def __repr__(self): + return f"(target_class_id={self.target_class_id}, target={self.target}, member_name={self.member_name}, member_id={self.member_id})" + + def __call__(self, *args): + """Calls the bound method with the given `args`. + + Returns: + A Python primitive (bool, int, float, str) if applicable, otherwise a JavaObject. + """ + task_recorder = TaskRecorder.active() + if task_recorder: + log(f"Recording method call `{self.member_name}` on class {self.target_class_id}") + # Need to iterate all methods to find a method with matching name and arg count to determine + # the return type to use for dependent expressions or tasks. + methods = java_call_method(self.target_class_id, Class_getMethods_id) + target_class_name = _get_class_info(self.target_class_id).class_name() + num_methods = java_array_length(methods) + log(f"Checking {num_methods} methods from `{target_class_name}` to match `{self.member_name}`") + with AutoReleasePool() as auto: + for i in range(num_methods): + method = java_array_index(methods, i) + method_name = java_to_string(auto(java_call_method(method, Method_getName_id))) + num_args = int(java_to_string(auto(java_call_method(method, Method_getParameterCount_id)))) + if method_name == self.member_name and num_args == len(args): + return_type_id = _promote_primitive_types( + java_call_method(method, Method_getReturnType_id)) + task = RecordedTask( + return_type_id, + java_call_method.as_task(self.target, self.member_id, + *[to_java_type(a) for a in args]), + f"method `{self.member_name}`") + task_recorder.append_java_task(task.task) + return task + raise ValueError(f"No method found named `{self.member_name}` with {len(args)} arg(s).") + + if type(self.target) is Task: + raise ValueError(f"Unexpected Task outside of recording mode: {self.target}") + + result = java_call_method(self.target, self.member_id, + *[to_java_type(a) for a in args]) + return from_java_type(result) + + +class JavaInt(JavaObject): + """JavaObject subclass for Java Integer.""" + def __init__(self, value: int): + super().__init__(java_int(value)) + + +class JavaFloat(JavaObject): + """JavaObject subclass for Java Float.""" + def __init__(self, value: float): + super().__init__(java_float(value)) + + +class JavaString(JavaObject): + """JavaObject subclass for Java String.""" + def __init__(self, value: str): + super().__init__(java_string(value)) + + +class JavaClass(JavaObject): + """JavaObject subclass for Java class objects.""" + def __init__(self, name): + super().__init__(find_java_class(name)) + self.class_name = name + self.ctor: JavaHandle = None + self._is_enum: bool = None + debug_log(f"Creating class {name} with id {self.id}") + + def __repr__(self): + return f'JavaClass("{self.class_name}")' + + def is_enum(self): + """Returns `True` if this class represents a Java enum type.""" + if self._is_enum is None: + with AutoReleasePool() as auto: + self._is_enum = from_java_type(auto(java_call_method(self.id, Class_isEnum_id))) + return self._is_enum + + def __getattr__(self, name): + """Accesses the static field or static method named `name` on this Java class. + + Args: + name: name of a static field or static method on this Java class. + + Returns: + If `name` matches a static field on this Java class, then return the value of that field as + a new JavaObject. Otherwise return a `JavaBoundMember` equivalent to the Java expression + `ThisClass::staticMethodName`. + """ + if name == "class_": + return JavaObject(self.id, ref=self.ref) + + if self.is_enum(): + valueOf = find_java_member(self.id, "valueOf") + with AutoReleasePool() as auto: + result = JavaObject(java_call_method(_null_id, valueOf, auto(java_string(name)))) + return result + + binding = JavaBoundMember(self.id, _null_id, name) + + task_recorder = TaskRecorder.active() + if task_recorder: + log(f"Recording JavaClass get `{name}`") + # TODO(maxuser): Provide disambiguation when field and method have same name, e.g. "foo.m_name()" + log(f"Field names for `{self.class_name}`: {_get_class_info(self.id).field_names()}") + if name not in _get_class_info(self.id).field_names(): + return binding + try: + field_id = java_call_method(self.id, Class_getField_id, java_string(name)) + field_type_id = _promote_primitive_types(java_call_method(field_id, Field_getType_id)) + task = RecordedTask( + field_type_id, + java_access_field.as_task(self.id, binding.member_id), + f"field `{name}`") + task_recorder.append_java_task(task.task) + return task + except Exception as e: + debug_log(f"lib_java.py: caught exception accessing field `{name}`: {e}") + log(f"Returning binding for JavaClass `{name}`") + return binding + + # TODO(maxuser): Provide disambiguation when field and method have same name, e.g. "foo.m_name()" + if name not in _get_class_info(self.id).field_names(): + return binding + try: + field = java_access_field(self.id, binding.member_id) + return from_java_type(field) + except Exception as e: + debug_log(f"lib_java.py: caught exception accessing field `{name}`: {e}") + return binding + + def __call__(self, *args): + """Calls the constructor for this Java class that takes the given `args`, if applicable. + + Returns: + JavaObject representing the newly constructed Java object. + """ + if self.ctor is None: + self.ctor = JavaObject(java_ctor(self.id)) + + task_recorder = TaskRecorder.active() + if task_recorder: + task = RecordedTask( + self.id, + java_new_instance.as_task(self.ctor.id, *[to_java_type(a) for a in args]), + f"ctor `{self.class_name}`") + task_recorder.append_java_task(task.task) + return task + + return JavaObject(java_new_instance(self.ctor.id, *[to_java_type(a) for a in args])) + + +class RecordedTask: + def __init__(self, type_id: JavaHandle, task: Task, desc: str): + self.type_id = type_id + self.task = task + self.desc = desc + log("recorded task:", desc) + + def __getattr__(self, name): + binding = JavaBoundMember(self.type_id, self.task, name) + + task_recorder = TaskRecorder.active() + if not task_recorder: + raise RuntimeError( + f"Cannot call getattr '{name}' on RecordedTask with recording mode disabled: {self.task}") + + if name not in _get_class_info(self.type_id).field_names(): + return binding + try: + with AutoReleasePool() as auto: + field_id = auto(java_call_method(self.type_id, Class_getField_id, java_string(name))) + field_type_id = _promote_primitive_types(java_call_method(field_id, Field_getType_id)) + task = RecordedTask( + field_type_id, + java_access_field.as_task(self.task, binding.member_id), + f"field `{name}`") + task_recorder.append_java_task(task.task) + return task + except Exception as e: + debug_log(f"lib_java.py: caught exception accessing field `{name}`: {e}") + return binding + +null = JavaObject(0) + +def callScriptFunction(func_name: str, *args) -> JavaObject: + """Calls the given Minescript script function. + + Args: + func_name: name of a Minescript script function + args: args to pass to the given script function + + Returns: + The return value of the given script function as a Python primitive type or JavaObject. + """ + return from_java_type(java_call_script_function(func_name, *[to_java_type(a) for a in args])) + +class JavaFuture: + """Java value that will become available in the future when an async function completes.""" + + def __init__(self, future): + self.future = future + + def wait(self, timeout=None): + """Waits for the async function to complete. + + Args: + timeout: if not `None`, timeout in seconds to wait on the async function to complete + + Returns: + Python primitive value or JavaObject returned from the async function upon completion. + """ + return from_java_type(self.future.wait(timeout=timeout)) + +def callAsyncScriptFunction(func_name: str, *args) -> JavaFuture: + """Calls the given Minescript script function asynchronously. + + Args: + func_name: name of a Minescript script function + args: args to pass to the given script function + + Returns: + `JavaFuture` that will hold the return value of the async funcion when complete. + """ + return JavaFuture(java_call_script_function.as_async(func_name, *[to_java_type(a) for a in args]))