diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f2b8fb8..e2d89d2 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -38,4 +38,4 @@ jobs: pyright - name: Test with pytest run: | - pytest + pytest \ No newline at end of file diff --git a/autohotpy/__init__.py b/autohotpy/__init__.py index f95ea19..5edc3f4 100644 --- a/autohotpy/__init__.py +++ b/autohotpy/__init__.py @@ -1,10 +1,30 @@ from typing import TYPE_CHECKING from .global_state import config -from . import ahk_run from .convenience.py_lib import pylib as Python +from .ahk_run import get_ahk +from ._unset_type import UNSET +from .proxies._seq_iter import iterator if TYPE_CHECKING: - from autohotpy.static_typing import AhkBuiltins as ahk -else: - ahk = ahk_run.run_str() + import autohotpy.static_typing + + ahk = autohotpy.static_typing.AhkBuiltins() + + +__all__ = ["ahk", "get_ahk", "Python", "config", "UNSET", "iterator"] + + +def __getattr__(__name): + global ahk + if __name == "ahk": + ahk = get_ahk() + return ahk + + import sys + + raise AttributeError( + f"autohotpy has no attribute named {__name}", + name=__name, + obj=sys.modules[__name__], + ) diff --git a/autohotpy/_unset_type.py b/autohotpy/_unset_type.py new file mode 100644 index 0000000..754d71c --- /dev/null +++ b/autohotpy/_unset_type.py @@ -0,0 +1,26 @@ +from typing import Any, Self + + +class UnsetType: + __slots__ = ("__weakref__",) + _UNSET = None + + def __new__(cls) -> Self: + if cls._UNSET is None: + cls._UNSET = super().__new__(cls) + return cls._UNSET + + def __repr__(self) -> str: + return "UNSET" + + def __str__(self) -> str: + return "" + + def __bool__(self) -> bool: + return False + + def __reduce__(self) -> str | tuple[Any, ...]: + return (UnsetType, ()) + + +UNSET = UnsetType() diff --git a/autohotpy/ahk_instance.py b/autohotpy/ahk_instance.py index 253e11b..884f5ce 100644 --- a/autohotpy/ahk_instance.py +++ b/autohotpy/ahk_instance.py @@ -1,10 +1,15 @@ from __future__ import annotations +from collections.abc import Generator +from concurrent.futures import Future +from contextlib import contextmanager +from queue import Empty, Queue +from socket import timeout import sys +from time import sleep, time import traceback -from typing import Any +from typing import TYPE_CHECKING, Any, NoReturn, final from enum import StrEnum, auto -import os import threading from ctypes import c_int, c_uint, c_wchar_p from autohotpy.proxies.ahk_obj_factory import AhkObjFactory @@ -16,8 +21,9 @@ from autohotpy.exceptions import AhkException, ExitApp from .communicator import ahkdll -from .global_state import thread_state -from contextlib import chdir + +if TYPE_CHECKING: + from autohotpy.static_typing import AhkBuiltins class AhkState(StrEnum): @@ -28,116 +34,126 @@ class AhkState(StrEnum): class AhkInstance: - def __init__(self, *script) -> None: - thread_state.current_instance = self - self._autoexec_condition = threading.Condition() - self._job_queue: c_wchar_p | bool = False + def __init__(self, ctrl_c_exitapp: bool) -> None: + self.ctrl_c_exitapp = ctrl_c_exitapp + self._queue = Queue[tuple[c_wchar_p, Future[None]] | None](maxsize=1) + self._addscript_queue = Queue[None](maxsize=1) self._error = None self._exit_code: int | None = None self._exit_reason: str = "" - self.state: AhkState = AhkState.INITIALIZING + self._initialized = False self._thread_id = c_uint(ahkdll.new_thread("Persistent", "", "", c_int(1))) self._py_thread_id = threading.get_ident() + self._ahk_mainthread: int | None = None self.communicator = Communicator( on_idle=self._autoexec_thread_callback, on_exit=self._exit_app_callback, on_error=self._error_callback, on_call=self._call_method_callback, + post_init=self._post_init_callback, ) # inject a backend library into the script, for communicating with python modded_script = self.communicator.create_init_script() + self.add_script(modded_script) + # self._post_init is called here from the ahk mainthread - self._add_script(modded_script, runwait=1) + self._globals = AhkScript(self) - self.add_script(*script) + def get_globals(self) -> AhkBuiltins: + return self._globals # type: ignore - def add_script(self, *script: str): - if thread_state.get_thread_type(self) != "autoexec": + def add_script(self, *script_lines: str): + if self._py_thread_id != threading.get_ident(): raise RuntimeError( - "Global-scope ahk statements cannot be run in the middle of a function. Try running this in a different thread." + "Global-scope ahk statements cannot be run in the middle of a function. Try running this at the module level, or use a function instead." ) - with (cond := self._autoexec_condition): - while self._job_queue is not False or self.state == AhkState.RUNNING: - if self.state == AhkState.CLOSED: - raise RuntimeError("Interpreter is already closed") - cond.wait(timeout=1) - - # request the old script to end, and wait for it to do so - if self.state != AhkState.INITIALIZING: - self._job_queue = True - cond.notify_all() - while self._job_queue is not False: - cond.wait(timeout=1) - - # mark script as running again - self.state = AhkState.RUNNING - cond.notify_all() - - # run the script - user_script: str = self.communicator.create_user_script(script) - self._add_script(user_script, runwait=2) - - # wait for it to pass control back to this thread. - while self.state == AhkState.RUNNING: - cond.wait(timeout=1) + + self.check_exit() + + if self._initialized: + # request the currently-paused script to go into persistent mode + self._queue.put(None) + + # run the new script + user_script: str = self.communicator.create_user_script(script_lines) + self._add_script(user_script, runwait=2) + + while True: + try: + self._addscript_queue.get(timeout=0.5) + except Empty: + self.check_exit() + continue + self.check_exit() + break def _add_script(self, script: str, runwait) -> None: ahkdll.add_script(script, c_int(runwait), self._thread_id) + def check_exit(self): + if self._exit_code is None: + return + if self._exit_code == -1073741510: + raise KeyboardInterrupt() from None + else: + raise ExitApp(self._exit_reason, self._exit_code) from None + def add_hotkey(self, factory: HotkeyFactory): factory.inst = self factory.create() - def _match_state(self, state): - if self.state == state: - return True - elif state != self.state == AhkState.CLOSED: - assert self._exit_code is not None - raise ExitApp(self._exit_reason, self._exit_code) - - def run_forever(self) -> None: - with (cond := self._autoexec_condition): - # indicate to Ahk's main thread that it can go into persistent mode - self._job_queue = True - self._autoexec_condition.notify_all() - while not self.state == AhkState.CLOSED: - # Timeout allows for KeyboardInterrupts if you're in the main thread. - cond.wait(timeout=1) + def run_forever(self) -> NoReturn: + + # request the currently-paused script to go into persistent mode + self._queue.put(None) + while True: + sleep(0.5) + self.check_exit() # raises ExitApp or KeyboardInterrupt when done def _call_autoexec(self, arg_data: c_wchar_p): - cond = self._autoexec_condition - with cond: - while self._job_queue is not False: - cond.wait(timeout=1) - self._job_queue = job = arg_data - cond.notify_all() - while self._job_queue is arg_data: - cond.wait(timeout=1) + fut = Future[None]() + assert self._queue.empty() + self._queue.put((arg_data, fut)) + + while True: + try: + fut.result(timeout=0.5) + except TimeoutError: + self.check_exit() + continue + self.check_exit() + break + + # @contextmanager + # def mark_safe_thread(self) -> Generator[None, None, None]: + # id = threading.get_ident() + # new = id in self._safe_threads + # self._safe_threads.add(id) + # try: + # yield + # finally: + # if new: + # self._safe_threads.remove(id) def _autoexec_thread_callback(self): - with (cond := self._autoexec_condition): - self.state = AhkState.IDLE - cond.notify_all() - - while True: - cond.wait_for(lambda: self._job_queue is not False) - job: bool | c_wchar_p = self._job_queue - - assert job is not False - - # set to True if something has been appended to the script. - if job is True: - self._job_queue = False - cond.notify_all() - return - else: - self.communicator.call_func(job) - self._job_queue = False - cond.notify_all() + + # wake up python's main thread + self._addscript_queue.put(None) + + while True: + task = self._queue.get() + + # set to None if something has been appended to the script. + if task is None: + return + else: + job, fut = task + self.communicator.call_func(job) + fut.set_result(None) def call_method( self, @@ -147,17 +163,21 @@ def call_method( kwargs: dict[str, Any] | None = None, factory: AhkObjFactory | None = None, ) -> Any: + self.check_exit() + if factory is None: factory = AhkObjFactory() factory.inst = self - thread_type = thread_state.get_thread_type(self) - if thread_type == "ahk": + thread = threading.get_ident() + if self._py_thread_id == thread: + call = self._call_autoexec + + elif thread == self._ahk_mainthread: # TODO: thread safety call = self.communicator.call_func - elif thread_type == "external": + + else: call = self.communicator.call_func_threadsafe - else: # thread_type == 'autoexec' - call = self._call_autoexec return self.communicator.call_method(obj, method, args, kwargs, factory, call) @@ -175,13 +195,14 @@ def free(self, obj: AhkObject): if obj._ahk_ptr is not None: self.communicator.free_ahk_obj(obj._ahk_ptr) - def _exit_app_callback(self, reason, code): - with self._autoexec_condition: - self._exit_code = code - self._exit_reason = reason - self.state = AhkState.CLOSED - self._autoexec_condition.notify_all() - return 0 + def _post_init_callback(self): + self._ahk_mainthread = threading.get_ident() + self._initialized = True + + def _exit_app_callback(self, reason: str, code: int): + self._exit_reason = reason + self._exit_code = code + return 0 def _error_callback(self, e): if isinstance(e, BaseException): @@ -204,12 +225,15 @@ def _call_method_callback(self, data: dict) -> tuple[bool, Any]: args = [vfd(arg, factory=factory) for arg in data["args"]] kwargs = {} # kwargs = {k: vfd(v, factory=factory) for k, v in data["kwargs"].items()} + # TODO: kwargs if method_name: func = getattr(obj, method_name) else: func = obj + # print(f"calling {func}") + try: ret_val = func(*args, **kwargs) success = True diff --git a/autohotpy/ahk_run.py b/autohotpy/ahk_run.py index d1f8e6b..13c4b99 100644 --- a/autohotpy/ahk_run.py +++ b/autohotpy/ahk_run.py @@ -1,46 +1,56 @@ from __future__ import annotations -import sys -from typing import Any -from autohotpy.ahk_instance import AhkInstance -import signal +from threading import RLock +import threading +from typing import TYPE_CHECKING + + from ctypes import windll -from autohotpy.proxies.ahk_script import AhkScript -from autohotpy.global_state import thread_state, config, global_state +from autohotpy.global_state import config, global_state + + +# # if in main thread, hook into KeyboardInterrupts +# if config.ctrl_c_exitapp and thread_state.is_main_thread: +# # signal.signal(signal.SIGINT, lambda num, frame: thread_state.current_script.ExitApp(-1073741510)) +# # TODO +# signal.signal(signal.SIGINT, lambda num, frame: sys.exit()) + +# + +lock = RLock() + + +if TYPE_CHECKING: + from autohotpy.static_typing import AhkBuiltins -def run_str(*script: str) -> AhkScript: - if thread_state.current_instance is None: - # apply config - with global_state.lock: - # if in main thread, hook into KeyboardInterrupts - if config.ctrl_c_exitapp and thread_state.is_main_thread: - # signal.signal(signal.SIGINT, lambda num, frame: thread_state.current_script.ExitApp(-1073741510)) - # TODO - signal.signal(signal.SIGINT, lambda num, frame: sys.exit()) +_ahk: AhkBuiltins | None = None - # set dpi scaling if not already done - if config.dpi_scale_mode is not None and not global_state.dpi_mode_is_set: - windll.shcore.SetProcessDpiAwareness(config.dpi_scale_mode) - global_state.dpi_mode_is_set = True +def get_ahk() -> AhkBuiltins: + global _ahk + if _ahk is None: + with lock: + if _ahk is None: + if ( + config.dpi_scale_mode is not None + and not global_state.dpi_mode_is_set + ): + windll.shcore.SetProcessDpiAwareness(config.dpi_scale_mode) + global_state.dpi_mode_is_set = True - AhkInstance(*script) + _ahk = new_interpreter( + ctrl_c_exitapp=config.ctrl_c_exitapp + and threading.main_thread().ident == threading.get_ident() + ) - elif not thread_state.is_autoexec_thread: - raise RuntimeError( - "Autohotkey scripts can only be loaded by the auto-execute thread." - ) - else: - thread_state.current_instance.add_script(*script) + return _ahk - assert thread_state.current_instance is not None - return AhkScript(thread_state.current_instance) +def new_interpreter(ctrl_c_exitapp: bool = False) -> AhkBuiltins: + from autohotpy.ahk_instance import AhkInstance + with lock: + instance = AhkInstance(ctrl_c_exitapp=ctrl_c_exitapp) -def include(script: str | None = None) -> AhkScript: - if script is not None: - return run_str(f"#include {script}") - else: - return run_str() + return instance.get_globals() diff --git a/autohotpy/communicator/communicator.py b/autohotpy/communicator/communicator.py index 7fe716c..209adfa 100644 --- a/autohotpy/communicator/communicator.py +++ b/autohotpy/communicator/communicator.py @@ -1,18 +1,18 @@ from __future__ import annotations from ctypes import CFUNCTYPE, c_int, c_uint64, c_wchar_p import json -from typing import Any, Callable -from autohotpy.proxies.ahk_obj_factory import AhkObjFactory +from typing import TYPE_CHECKING, Any, Callable +from autohotpy._unset_type import UNSET from autohotpy.proxies.ahk_object import AhkObject -from autohotpy.communicator.script_inject.Callbacks import Callbacks +from autohotpy.communicator.script_inject.callbacks import Callbacks from autohotpy.exceptions import ExitApp, throw from autohotpy.communicator.references import ReferenceKeeper -from autohotpy.communicator.script_inject.Callbacks import addr_of +from autohotpy.communicator.script_inject.callbacks import addr_of from autohotpy.communicator.dtypes import DTypes from autohotpy.proxies.var_ref import VarRef - -UNSET = object() +if TYPE_CHECKING: + from autohotpy.proxies.ahk_obj_factory import AhkObjFactory class Communicator: @@ -22,11 +22,13 @@ def __init__( on_exit: Callable, on_error: Callable, on_call: Callable, + post_init: Callable[[], None], ): self.on_idle = on_idle self.on_exit = on_exit self.on_error = on_error self.on_call = on_call + self.post_init = post_init self.py_references = ReferenceKeeper() self.callbacks = Callbacks(self) @@ -39,24 +41,33 @@ def create_user_script(self, script: tuple[str, ...]): def value_from_data(self, data, factory: AhkObjFactory | None) -> Any: if isinstance(data, dict): - if data["dtype"] == DTypes.AHK_OBJECT: + dtype = DTypes(data["dtype"]) + if dtype in ( + DTypes.AHK_OBJECT, + DTypes.VARREF, + DTypes.AHK_MAP, + DTypes.AHK_ARRAY, + ): assert factory is not None return factory.create( ptr=int(data["ptr"]), type_name=data["type_name"], + dtype=dtype, immortal=bool(data["immortal"]), ) - if data["dtype"] == DTypes.INT: + if dtype == DTypes.UNSET: + return UNSET + if dtype == DTypes.INT: return int(data["value"]) - if data["dtype"] == DTypes.PY_OBJECT: + if dtype == DTypes.PY_OBJECT: return self.py_references.obj_from_ptr(int(data["ptr"])) - if data["dtype"] == DTypes.VARREF: - assert factory is not None - return factory.create_varref(int(data["ptr"])) + else: return data def value_to_data(self, value): + if value is UNSET: + return dict(dtype=DTypes.UNSET.value) if isinstance(value, bool): value = int(value) if isinstance(value, VarRef): @@ -81,12 +92,16 @@ def call_method( factory: AhkObjFactory, _call: Callable, ) -> Any: - ret_val: Any = UNSET + + result: Any = UNSET + success: int = 0 @CFUNCTYPE(c_int, c_wchar_p) def ret_callback(val_data: str): - nonlocal ret_val + nonlocal result, success ret_val = json.loads(val_data) + result = self.value_from_data(ret_val["value"], factory) + success = int(ret_val["success"]) return 0 obj_or_globals = ( @@ -111,14 +126,12 @@ def ret_callback(val_data: str): ) ) - _call(arg_data) # sets ret_val - - if ret_val is UNSET: - raise ExitApp("unknown", 1) + _call(arg_data) # sets result - result = self.value_from_data(ret_val["value"], factory) + # if ret_val is UNSET: + # raise ExitApp("unknown", 1) - if int(ret_val["success"]): + if success: return result else: throw(result) @@ -149,6 +162,8 @@ def _set_ahk_func_ptrs( )(put_return_ptr) self.globals_ptr = globals_ptr + self.post_init() + return 0 def call_callback(self, call_data: str): diff --git a/autohotpy/communicator/dtypes.py b/autohotpy/communicator/dtypes.py index 513820c..837c255 100644 --- a/autohotpy/communicator/dtypes.py +++ b/autohotpy/communicator/dtypes.py @@ -7,15 +7,18 @@ class DTypes(StrEnum): NONE = auto() + UNSET = auto() INT = auto() FLOAT = auto() STR = auto() VARREF = auto() AHK_OBJECT = auto() - AHK_IMMORTAL = auto() + AHK_MAP = auto() + AHK_ARRAY = auto() + # AHK_IMMORTAL = auto() PY_OBJECT = auto() - PY_IMMORTAL = auto() - CLASS = auto() + # PY_IMMORTAL = auto() + # CLASS = auto() ERROR = auto() @@ -36,10 +39,12 @@ class DTypesFut(Enum): FLOAT = DT(float) STR = DT(str) AHK_OBJECT = DT(None) - AHK_IMMORTAL = DT(None) + AHK_MAP = DT(None) + AHK_ARRAY = DT(None) + # AHK_IMMORTAL = DT(None) PY_OBJECT = DT(None) - PY_IMMORTAL = DT(None) - CLASS = DT(type) + # PY_IMMORTAL = DT(None) + # CLASS = DT(type) ERROR = DT(None) @nonmember diff --git a/autohotpy/communicator/references.py b/autohotpy/communicator/references.py index c75584a..75c9070 100644 --- a/autohotpy/communicator/references.py +++ b/autohotpy/communicator/references.py @@ -1,7 +1,17 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal + +_debug: References | None = None + + +def set_debug(debug: bool): + global _debug + if debug: + _debug = References() + else: + _debug = None @dataclass(slots=True) @@ -41,11 +51,13 @@ def remove(self, obj): def decrement_obj(self, obj): self.decrement_ptr(id(obj)) - def decrement_ptr(self, ptr: int): + def decrement_ptr(self, ptr: int) -> RefWrapper | None: ref = self._dict[ptr] ref.count -= 1 if ref.count <= 0: del self._dict[ptr] + return ref + return None def get(self, ptr: int): return self._dict[ptr].value @@ -72,6 +84,7 @@ class ReferenceKeeper: def __init__(self) -> None: self.references = References() self.immortals = References() + self.collected = References() def obj_to_ptr_add_ref(self, obj) -> int: if id(obj) not in self.immortals: @@ -88,13 +101,26 @@ def obj_to_immortal_ptr(self, obj) -> int: return id(obj) def obj_from_ptr(self, ptr: int) -> Any: - if ptr in self.immortals: - return self.immortals.get(ptr) - return self.references.get(ptr) + try: + if ptr in self.immortals: + return self.immortals.get(ptr) + return self.references.get(ptr) + except KeyError: + if _debug is not None: + val = _debug.get(ptr) + raise RuntimeError( + f"Tried to access an object that has already been cleared: ptr={ptr}, value={val!r}'" + ) + else: + raise RuntimeError( + f"Tried to access ptr {ptr} which has already been cleared. Try debugging with autohotpy.communicator.references.set_debug(True)" + ) from None def obj_free(self, ptr: int): if ptr not in self.immortals: - self.references.decrement_ptr(ptr) + ref = self.references.decrement_ptr(ptr) + if _debug is not None and ref is not None: + _debug.add(ref.value) def get_refcount(self, obj_or_ptr: int | Any) -> int | Literal["immortal"]: if isinstance(obj_or_ptr, int): diff --git a/autohotpy/communicator/script_inject/Callbacks.py b/autohotpy/communicator/script_inject/Callbacks.py index d611f91..7400c3d 100644 --- a/autohotpy/communicator/script_inject/Callbacks.py +++ b/autohotpy/communicator/script_inject/Callbacks.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from collections.abc import MutableMapping as Mapping + from autohotpy.convenience.py_lib import pylib if TYPE_CHECKING: @@ -57,16 +59,25 @@ def __init__(self, comm: Communicator) -> None: self.consts = PythonConsts( getattr=otim(getattr), setattr=otim(setattr), - none=otim(setattr), + none=otim(None), on_error=otim(comm.on_error), pylib=otim(pylib), iter=otim(iter), next=otim(next), StopIteration=otim(StopIteration), ) + self.mut_map_mixin = MutableMappingMixin( + keys=otim(Mapping.keys), + items=otim(Mapping.items), + values=otim(Mapping.values), + pop=otim(Mapping.pop), + popitem=otim(Mapping.popitem), + update=otim(Mapping.update), + setdefault=otim(Mapping.setdefault), + ) def create_init_script(self): - return create_injection_script(self.ptrs, self.consts) + return create_injection_script(self.ptrs, self.consts, self.mut_map_mixin) def create_user_script(self, script): return create_user_script(script, self.ptrs) @@ -91,3 +102,14 @@ class PythonConsts: iter: int next: int StopIteration: int + + +@dataclass +class MutableMappingMixin: + keys: int + items: int + values: int + pop: int + popitem: int + update: int + setdefault: int diff --git a/autohotpy/communicator/script_inject/create_script.py b/autohotpy/communicator/script_inject/create_script.py index d00cebd..c009447 100644 --- a/autohotpy/communicator/script_inject/create_script.py +++ b/autohotpy/communicator/script_inject/create_script.py @@ -4,10 +4,11 @@ from os import path from typing import TYPE_CHECKING + from ..dtypes import DTypes if TYPE_CHECKING: - from .Callbacks import CallbackPtrs, PythonConsts + from .callbacks import CallbackPtrs, PythonConsts, MutableMappingMixin def include(name): @@ -25,7 +26,9 @@ def create_user_script(script: tuple[str, ...], f: CallbackPtrs) -> str: """ -def create_injection_script(f: CallbackPtrs, a: PythonConsts) -> str: +def create_injection_script( + f: CallbackPtrs, a: PythonConsts, m: MutableMappingMixin +) -> str: cwd = os.getcwd() dtype_enum = "\t\t\t\t\n".join( @@ -40,6 +43,11 @@ def create_injection_script(f: CallbackPtrs, a: PythonConsts) -> str: f"static {k} := _py_object_from_id({v})" for k, v in asdict(a).items() ) + map_mixin = "\t\t\t\t\n".join( + f"""Map.Prototype.DefineProp("{k}", {{get: (p*) => {{}}, call: _py_object_from_id({v})}})""" + for k, v in asdict(m).items() + ) + return f""" #include "{cwd}" SetWorkingDir "{cwd}" @@ -74,6 +82,8 @@ class _Python {{ {consts_enum} }} Python := _Python.pylib + + {map_mixin} DllCall({f.give_pointers} diff --git a/autohotpy/communicator/script_inject/py_call.ahk b/autohotpy/communicator/script_inject/py_call.ahk index 613ef7e..3f08273 100644 --- a/autohotpy/communicator/script_inject/py_call.ahk +++ b/autohotpy/communicator/script_inject/py_call.ahk @@ -6,11 +6,14 @@ class _py_caller_job { __New(obj, method, args) { if (args.Length and (args[-1] is Kwargs)) { - kwds := map() + kwds := map() ; TODO: kwargs } else { kwds := map() } + this.args := args ; we need to keep a reference to the args until after the call + this.kwargs := kwds + this.ret_val := "" this.success := false static vtd := ObjBindMethod(_PyCommunicator, "value_to_data") @@ -24,7 +27,7 @@ class _py_caller_job { "obj", vtd(obj) ,"method", vtd(method) ,"args", arg_data - ,"kwargs", kwargs + ,"kwargs", kwds ,"ret_call_p", String(ObjPtr(this)) )) @@ -34,21 +37,30 @@ class _py_caller_job { DllCall(_PyCallbacks.CALL_METHOD, "str", this.call_data, "int") if Integer(this.success) { + if this.ret_val == _PyCommunicator.UNSET_ { + throw Error('A function tried to return an unset variable') + } return this.ret_val } else { throw this.ret_val } } + noop() { + + } } _py_call_method(obj, method, args) { - return _py_caller_job(obj, method, args).Call() + job := _py_caller_job(obj, method, args) + result := job.Call() + return result } _py_put_return_value(job_p, data_p) { - job := ObjFromPtrAddRef(job_p) + job := ObjFromPtrAddRef(job_p) ; TODO: this maybe leaks memory? nvm, I don't think so, but needs testing data := JSON.Parse(StrGet(data_p)) - job.ret_val := _PyCommunicator.value_from_data(data["value"]) + _PyCommunicator.value_from_data(data["value"], &ret_val) + job.ret_val := ret_val job.success := data["success"] } diff --git a/autohotpy/communicator/script_inject/py_communicator.ahk b/autohotpy/communicator/script_inject/py_communicator.ahk index 13ee221..883cb2e 100644 --- a/autohotpy/communicator/script_inject/py_communicator.ahk +++ b/autohotpy/communicator/script_inject/py_communicator.ahk @@ -7,11 +7,24 @@ _py_call_ahk_function(param_ptr) { } args := call_info['args'] for i, v in args { - call_info['args'][i] := _PyCommunicator.value_from_data(v) + _PyCommunicator.value_from_data(v, &arg_entry) + if arg_entry != _PyCommunicator.UNSET_ + args[i] := arg_entry + else + args.Delete(i) } - obj := _PyCommunicator.value_from_data(call_info['obj']) - method := _PyCommunicator.value_from_data(call_info['method']) + kwds := call_info['kwargs'] + if kwds.Count { + kwds.Base := Kwargs.Prototype + args.Push(kwds) + } + _PyCommunicator.value_from_data(call_info['obj'], &obj) + _PyCommunicator.value_from_data(call_info['method'], &method) + try { + if obj == _PyCommunicator.UNSET_ or method == _PyCommunicator.UNSET_ { + throw Error('Callable or Method was unset') + } result := obj.%method%(args*) result_data := map("success", true, "value", _PyCommunicator.value_to_data(result)) } @@ -32,16 +45,22 @@ _py_get_ahk_attr(obj, name) { return obj.%name% } -_py_set_ahk_attr(obj, name, value) { - obj.%name% := value +_py_set_ahk_attr(obj, name, value := unset) { + if IsSet(value) + obj.%name% := value + else + obj.DeleteProp(name) } _py_getitem(obj, params*) { return obj[params*] } -_py_setitem(obj, value, params*) { - obj[params*] := value +_py_setitem(obj, value := UNSET, params*) { + If IsSet(value) + obj[params*] := value + else + obj.Delete(params*) } _py_getprop(obj, prop, params*) { @@ -56,8 +75,14 @@ _py_instancecheck(inst, cls) { return inst is cls } +_py_subclasscheck(cls, basecls) { + return HasBase(cls, basecls) +} + class _PyCommunicator { + static UNSET_ := Object() + static __New() { this.call_ptr := CallbackCreate(_py_call_ahk_function, "F") this.call_threadsafe_ptr := CallbackCreate(_py_call_ahk_function) @@ -71,25 +96,31 @@ class _PyCommunicator { ) } - static value_from_data(val) { + static value_from_data(val, &out) { if val is Map { if ( val["dtype"] == _PyParamTypes.AHK_OBJECT or val["dtype"] == _PyParamTypes.VARREF ) { - val := ObjFromPtrAddRef(val["ptr"]) - return val + out := ObjFromPtrAddRef(val["ptr"]) + return } if val["dtype"] == _PyParamTypes.INT { - return Integer(val["value"]) + out := Integer(val["value"]) + return } if val["dtype"] == _PyParamTypes.PY_OBJECT { - return _py_object_from_id(val["ptr"]) + out := _py_object_from_id(val["ptr"]) + return + } + if val["dtype"] == _PyParamTypes.UNSET { + out := this.UNSET_ + return } - Msgbox 'error ' val["dtype"] " != " _PyParamTypes.AHK_OBJECT + throw Error('error ' val["dtype"] " != " _PyParamTypes.AHK_OBJECT) } - return val + out := val } static value_to_data(val) { @@ -97,26 +128,26 @@ class _PyCommunicator { ptr := val._py_id return map("dtype", _PyParamTypes.PY_OBJECT, "ptr", String(ptr)) } - if val is VarRef { - ptr := ObjPtrAddRef(val) - return map("dtype", _PyParamTypes.VARREF, "ptr", String(ptr)) - } - if IsObject(val) { - immortal := this.IsImmortal(val) - if immortal { - ptr := ObjPtr(val) - } else { - ptr := ObjPtrAddRef(val) - } - ; Msgbox immortal ", " Type(val) ", " (val is Func and val.IsBuiltIn) - - return map("dtype", _PyParamTypes.AHK_OBJECT, "ptr", String(ptr), "type_name", Type(val), 'immortal', immortal) - } + ; if val is VarRef { + ; ptr := ObjPtrAddRef(val) + ; return map("dtype", _PyParamTypes.VARREF, "ptr", String(ptr)) + ; } if val is Integer { return map("dtype", _PyParamTypes.INT, "value", String(val)) } - - return val + if val is Float or val is String { + return val + } + immortal := this.IsImmortal(val) + if immortal { + ptr := ObjPtr(val) + } else { + ptr := ObjPtrAddRef(val) + } + ; Msgbox immortal ", " Type(val) ", " (val is Func and val.IsBuiltIn) + dtype := (val is Map) ? _PyParamTypes.AHK_MAP : (val is Array ? _PyParamTypes.AHK_ARRAY : _PyParamTypes.AHK_OBJECT) + return map("dtype", _PyParamTypes.AHK_OBJECT, "ptr", String(ptr), "type_name", Type(val), 'immortal', immortal) + } static IsImmortal(_ahk_value) { diff --git a/autohotpy/communicator/script_inject/py_special.ahk b/autohotpy/communicator/script_inject/py_special.ahk index 9e2228e..7521613 100644 --- a/autohotpy/communicator/script_inject/py_special.ahk +++ b/autohotpy/communicator/script_inject/py_special.ahk @@ -3,7 +3,7 @@ _py_parameterize_generic(this, params*) { return this } ; Allow for Generic syntax. i.e. Array[int] -Object.DefineProp('__Item', {get: _py_parameterize_generic}) +(Object.DefineProp)(Class.Prototype, '__Item', {get: _py_parameterize_generic}) class ObjBindProp { __New(obj, prop_name) { @@ -17,16 +17,76 @@ class ObjBindProp { } } -_py_create_ref(this, value := "") { - return &value +_py_create_ref(this, value := unset) { + is_set := IsSet(value) + ref := &value + if not is_set + _py_unset_ref(ref) + return ref + +} + +_py_set_ref(ref, new_value) { + %ref% := new_value +} + +_py_unset_ref(ref_to_unset) { + static _empty := Array( , 0) + _empty.__Enum(1)(ref_to_unset) +} + +_py_delete_ref_value(ref, name) { + if name = 'value' { + _py_unset_ref(ref) + } + else { + throw PropertyError('VarRef has no property "' name '"') + } +} + +_py_deref(ref) { + return %ref% } VarRef.Call := _py_create_ref +VarRef.Prototype.DeleteProp := _py_delete_ref_value +(Object.DefineProp)(VarRef.Prototype, 'value', {get: _py_deref, set: _py_set_ref}) -_py_set_ref(&ref, value) { - ref := value +_py_object_from_kwargs(obj, kw := unset) { + if IsSet(kw) { + if kw is Kwargs { + for name, val in kw { + obj.%name% := val + } + } else { + throw TypeError('Too many arguments to Object()') + } + } } -_py_deref(&ref) { - return ref -} \ No newline at end of file +Object.Prototype.__New := _py_object_from_kwargs + + +_py_map_from_dict(map_, args*) { + if not args.Length + return + last := args.Pop() + if (last is Kwargs) { + for k, v in last { + args.Push(k, v) + } + } else { + args.Push(last) + } + + if Mod(args.Length, 2) == 1 { + first := args.RemoveAt(1) + for k in first { + args.InsertAt(1, k, first[k]) + } + } + map_.Set(args*) +} + +_py_new_map_from_items := Map.Prototype.__New +(Object.DefineProp)(Map.Prototype, '__New', {call: _py_map_from_dict}) \ No newline at end of file diff --git a/autohotpy/convenience/__init__.py b/autohotpy/convenience/__init__.py index e69de29..4014f81 100644 --- a/autohotpy/convenience/__init__.py +++ b/autohotpy/convenience/__init__.py @@ -0,0 +1,33 @@ +from __future__ import annotations + + +from typing import TYPE_CHECKING, Any, Literal + + +if TYPE_CHECKING: + from autohotpy.static_typing.classes import Prototype + +from autohotpy.proxies.ahk_object import AhkObject + + +def mro(obj: Any) -> list[Prototype | type]: + lst: list[Prototype | type] = [] + + if isinstance(obj, AhkObject): + call = obj._ahk_instance.call_method + ogb = lambda o: call(None, "ObjGetBase", (o,)) + proto: Prototype | Literal[""] = ogb(obj) + while proto: + lst.append(proto) + proto = ogb(proto) + return lst + + else: + tp = type(obj) + if isinstance(obj, type): + lst += obj.mro() + lst += type.mro(tp)[:-1] + else: + lst += tp.mro() + + return lst diff --git a/autohotpy/exceptions.py b/autohotpy/exceptions.py index 4306349..6b5cd99 100644 --- a/autohotpy/exceptions.py +++ b/autohotpy/exceptions.py @@ -1,4 +1,5 @@ from __future__ import annotations +import builtins from functools import cached_property from typing import TYPE_CHECKING, Any, Protocol, cast @@ -78,11 +79,23 @@ def __str__(self) -> str: return self.msg -class MemoryError(Error, MemoryError): +class MemoryError(Error, builtins.MemoryError): pass -class OSError(Error, OSError): +class OSError(Error, builtins.OSError): + pass + + +class TargetError(Error): + pass + + +class TimeoutError(Error, builtins.TimeoutError): + pass + + +class TypeError(Error, builtins.TypeError): pass @@ -98,15 +111,15 @@ class MethodError(MemberError): pass -class IndexError(Error, IndexError): +class IndexError(Error, builtins.IndexError): pass -class KeyError(IndexError, KeyError): +class KeyError(IndexError, builtins.KeyError): pass -class ValueError(Error, ValueError): +class ValueError(Error, builtins.ValueError): pass diff --git a/autohotpy/main.py b/autohotpy/main.py index 17b82d7..0b065c5 100644 --- a/autohotpy/main.py +++ b/autohotpy/main.py @@ -1,6 +1,5 @@ import argparse -from autohotpy.ahk_run import run_str from autohotpy import ahk @@ -26,4 +25,4 @@ def main(*args: str): if space.run_str: ahk[space.SCRIPT] else: - run_str(f"#include {space.SCRIPT}") + ahk[f"#include {space.SCRIPT}"] diff --git a/autohotpy/proxies/_copying.py b/autohotpy/proxies/_copying.py index 6bb3a31..e3e9bb8 100644 --- a/autohotpy/proxies/_copying.py +++ b/autohotpy/proxies/_copying.py @@ -1,17 +1,58 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from contextlib import contextmanager +import threading +from typing import TYPE_CHECKING, Any, Callable, Generator, NamedTuple, cast from autohotpy.proxies._seq_iter import iterator +from autohotpy.ahk_run import get_ahk if TYPE_CHECKING: from autohotpy.proxies.ahk_object import AhkObject - from autohotpy.static_typing.classes import object_ # type: ignore - from autohotpy.static_typing.classes import map as map_ # type: ignore -def _from_qualname(qualname: str, location) -> AhkObject: +class _Local(threading.local): + def __init__(self) -> None: + self.memo: dict[int | None, AhkObject] | None = None + + +_local = _Local() + + +class SavedAhkObj(NamedTuple): + func: Callable + args: tuple + + +@contextmanager +def _memo[T](obj: T) -> Generator[T, None, None]: + + from autohotpy.proxies.ahk_object import AhkObject + + memo = _local.memo + if memo is None: + new_memo = True + + _local.memo = memo = {} + else: + new_memo = False + + try: + if isinstance(obj, AhkObject): + if obj._ahk_ptr not in memo: + memo[obj._ahk_ptr] = obj + yield obj + else: + yield cast(T, memo[obj._ahk_ptr]) + else: + yield obj + finally: + if new_memo: + _local.memo = None + + +def _from_qualname(qualname: str, *, location: Any) -> AhkObject: # qualname = qualname.removeprefix("ahk.") @@ -27,86 +68,88 @@ def _from_qualname(qualname: str, location) -> AhkObject: ) from e -def load_from_qualname(qualname: str) -> AhkObject: +def load_from_qualname(qualname: str, *, location: Any = None) -> AhkObject: # if not qualname.startswith("ahk."): # raise ValueError(f'Invalid qualname "{qualname}" for pickling') - from autohotpy import ahk + if location is None: + location = get_ahk() - return _from_qualname(qualname, ahk) + return _from_qualname(qualname, location=location) -def load_from_own_props(base: Any, own_props: dict[str, Any]): - from autohotpy import ahk +def load_from_own_props( + base: Any, + own_props: dict[str, Any], + *, + location=None, +): - obj = ahk.Object() - - obj.Base = base + if location is None: + location = get_ahk() + obj = location.Object() for k, v in own_props.items(): - desc = ahk.Object() - desc.value = v - obj.DefineProp(k, desc) + setattr(obj, k, v) + + obj.Base = base return obj -def reduce_ahk_obj(self: AhkObject): - from autohotpy import ahk +def reduce_ahk_obj(self: AhkObject, location: Any = None): + with _memo(self) as obj: + objth: Any = obj - if TYPE_CHECKING: - obj = cast(object_.Object, self) - else: - obj = self - - if ahk._ahk_instance is not self._ahk_instance: - raise ValueError( - f"{self} cannot be pickled because it is not from the default ahk interpreter." - ) - - if self._ahk_type_name in ("Class", "Func", "Prototype"): - - qualname = self._ahk_name - - if load_from_qualname(qualname)._ahk_ptr != self._ahk_ptr: - raise ValueError(f"{qualname} is not a picklable qualname") - - func = load_from_qualname - args = (qualname,) - - elif self._ahk_type_name == "Array": - func = ahk.Array - args = tuple(self) - elif self._ahk_type_name == "Map": - func = ahk.Map - args = () - if TYPE_CHECKING: - obj = cast(map_.Map, obj) - for kv in iterator(obj, 2): - args += kv - - elif self._ahk_type_name in ( - "VarRef", - "ComValue", - "ComObjArray", - "ComObject", - "ComValueRef", - ): - raise ValueError(f'"{self._ahk_type_name}" type is not picklable') + if location is None: + location = obj._ahk_instance.get_globals() - else: - func = load_from_own_props + if obj._ahk_type_name in ("Class", "Func", "Prototype"): + + qualname = obj._ahk_name + + if load_from_qualname(qualname, location=location)._ahk_ptr != obj._ahk_ptr: + raise ValueError(f"{qualname} is not a picklable qualname") + + func = load_from_qualname + args = (qualname,) + + elif obj._ahk_type_name == "Array": + func = location.Array + args = tuple(obj) + elif obj._ahk_type_name == "Map": + func = location.Map + args = () + + for kv in iterator(objth, 2): + args += kv + + elif obj._ahk_type_name in ( + "VarRef", + "ComValue", + "ComObjArray", + "ComObject", + "ComValueRef", + ): + raise ValueError(f'"{obj._ahk_type_name}" type is not picklable') + + else: + func = load_from_own_props + + state = dict[str, tuple[bool, Any]]() + + for attr in objth.OwnProps(): + desc = objth.GetOwnPropDesc(attr) + if not hasattr(desc, "value"): + raise ValueError( + f'Cannot pickle dynamic property "{attr}" of object {objth!r}' + ) + with _memo(desc.value) as value: + state[attr] = value - state = dict[str, Any]() + with _memo(objth.Base) as base: - for attr in obj.OwnProps(): - desc = obj.GetOwnPropDesc(attr) - if not hasattr(desc, "value"): - raise ValueError( - f'Cannot pickle dynamic property "{attr}" of object {obj!r}' - ) - state[attr] = desc.value + args = (base, state) - args = (obj.Base, state) - return func, args + return func, args diff --git a/autohotpy/proxies/_seq_iter.py b/autohotpy/proxies/_seq_iter.py index eb8efc7..81b616e 100644 --- a/autohotpy/proxies/_seq_iter.py +++ b/autohotpy/proxies/_seq_iter.py @@ -1,6 +1,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Iterable, Literal, cast, overload +from autohotpy import exceptions +from autohotpy._unset_type import UNSET, UnsetType + if TYPE_CHECKING: from autohotpy.proxies.ahk_object import AhkObject @@ -9,6 +12,13 @@ from autohotpy.static_typing.classes import VarRef +def _val_or_unset[VT](ref: VarRef[VT]) -> VT | UnsetType: + try: + return ref.value + except exceptions.Error: + return UNSET + + @overload def fmt_item[TupleT: tuple](item: TupleT) -> TupleT: ... @overload @@ -75,6 +85,6 @@ def iterator(iterable, n=2): # type: ignore ] while enumer(*refs): if n == 1: - yield refs[0].value + yield _val_or_unset(refs[0]) else: - yield tuple(refs[i].value for i in range(n)) + yield tuple(_val_or_unset(r) for r in refs) diff --git a/autohotpy/proxies/ahk_obj_factory.py b/autohotpy/proxies/ahk_obj_factory.py index b317619..44e8e08 100644 --- a/autohotpy/proxies/ahk_obj_factory.py +++ b/autohotpy/proxies/ahk_obj_factory.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING +from autohotpy.communicator.dtypes import DTypes from autohotpy.proxies.ahk_object import AhkBoundProp, AhkObject from autohotpy.proxies.var_ref import VarRef @@ -15,7 +16,13 @@ class AhkObjFactory: bound_method_name: str = "" inst: AhkInstance = field(init=False) - def create(self, ptr: int, type_name: str, immortal: bool) -> AhkObject: + def create( + self, + ptr: int, + type_name: str, + immortal: bool, + dtype: DTypes, # TODO: handle Map and Array + ) -> AhkObject: if self.inst is None: raise RuntimeError( f"Unable to create ahk proxy object for '{type_name}' object." diff --git a/autohotpy/proxies/ahk_object.py b/autohotpy/proxies/ahk_object.py index d4a0727..697c977 100644 --- a/autohotpy/proxies/ahk_object.py +++ b/autohotpy/proxies/ahk_object.py @@ -2,6 +2,7 @@ from typing import Any, TYPE_CHECKING, Iterator from autohotpy import exceptions +from autohotpy._unset_type import UNSET from autohotpy.proxies._cached_prop import cached_prop from autohotpy.proxies._copying import reduce_ahk_obj from autohotpy.proxies._seq_iter import fmt_item, iterator @@ -9,13 +10,12 @@ if TYPE_CHECKING: from autohotpy.ahk_instance import AhkInstance - from autohotpy.proxies.var_ref import VarRef def _demangle(name: str) -> str: if name.endswith("__"): return name - lead, sep, end = name.rpartition("__") + _, sep, end = name.rpartition("__") return sep + end @@ -63,22 +63,25 @@ def __setattr__(self, __name: str, __value: Any) -> None: else: self._ahk_instance.set_attr(self, _demangle(__name), __value) + def __delattr__(self, __name: str) -> None: + self.__setattr__(__name, UNSET) + def __dir__(self): - def _dir(): - obj = self - while True: - try: - yield from obj.OwnProps() - obj = self._ahk_instance.get_attr( - obj, - "base", - ) - except AttributeError: - break - - yield from super().__dir__() - - return set(_dir()) + seen = set() + + obj = self + while True: + try: + seen.update(obj.OwnProps()) + obj = self._ahk_instance.get_attr( + obj, + "base", + ) + except AttributeError: + break + + seen.update(super(AhkObject, self).__dir__()) + return seen def __reduce__(self) -> str | tuple[Any, ...]: return reduce_ahk_obj(self) @@ -113,9 +116,19 @@ def __str__(self) -> str: def __repr__(self): if self._ahk_ptr is None: return super().__repr__() - if self._ahk_type_name in ("Func", "Class"): - return f"ahk.{self.__name__}" - return f"" + if self._ahk_type_name in ("Func", "Class", "Prototype"): + return f"ahk.{self._ahk_name}" + if self._ahk_type_name == "Object": + return f"ahk.Object({', '.join(name + '=' + repr(getattr(self, name)) for name in self.OwnProps())})" + if self._ahk_type_name == "Array": + return f"ahk.Array({', '.join(repr(elt) for elt in self)})" + # if self._ahk_type_name == "Map": TODO: fix this + # dct = dict(self.items()) + # if all(isinstance(k, str) for k in dct): + # return f"ahk.Map({', '.join(name + '=' + repr(v) for name, v in dct.items())})" + + # return f"" def __getitem__(self, item) -> Any: return self._ahk_instance.call_method( @@ -127,8 +140,28 @@ def __setitem__(self, item, value): None, "_py_setitem", (self, value, *fmt_item(item)) ) + def __delitem__(self, item): + self.__setitem__(item, UNSET) + + def __contains__(self, item): + self._ahk_instance.call_method(self, "Has", (item,)) + def __iter__(self) -> Iterator: - return iterator(self, 1) # type: ignore + return iterator(self, 1) # type: ignore # TODO: fix this + + def __len__(self) -> int: + try: + return self.Length + except AttributeError: + try: + return self.Count + except AttributeError: + raise NotImplementedError( + f'"{self._ahk_type_name}" object does not have a length.' + ) + + def __bool__(self) -> bool: + return True # TODO: fix bool operators? def __instancecheck__(self, instance: Any) -> bool: if self._ahk_type_name == "Class": @@ -140,17 +173,28 @@ def __instancecheck__(self, instance: Any) -> bool: elif self._ahk_type_name == "Array": return any(isinstance(instance, cls) for cls in self) - raise TypeError("isinstance() arg 2 must be a type, Array, tuple, or union") + raise TypeError("isinstance() arg 2 must be a type, array, tuple, or union") - def __subclasscheck__(self, subclass: type) -> bool: - return bool() + def __subclasscheck__(self, subcls: type) -> bool: + if self._ahk_type_name == "Class": + if not isinstance(subcls, AhkObject): + return False + return bool( + subcls._ahk_instance.call_method( + None, "_py_subclasscheck", (subcls, self) + ) + ) + elif self._ahk_type_name == "Array": + return any(issubclass(subcls, basecls) for basecls in self) + + else: + raise ValueError( + f"issubclass() arg 2 must be a type, Array, tuple, or union" + ) def __eq__(self, other: Any) -> bool: if isinstance(other, AhkObject): - return self._ahk_ptr == other._ahk_ptr or ( - self._ahk_type_name == "Class" == other._ahk_type_name - and self._ahk_name == other._ahk_name - ) + return self._ahk_ptr == other._ahk_ptr elif isinstance(other, str) and self._ahk_type_name == "Class": return self._ahk_name == other diff --git a/autohotpy/proxies/ahk_script.py b/autohotpy/proxies/ahk_script.py index a9569ef..308263b 100644 --- a/autohotpy/proxies/ahk_script.py +++ b/autohotpy/proxies/ahk_script.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING, NoReturn, TypeGuard +from xml.dom.minidom import Attr from autohotpy import exceptions +from autohotpy._unset_type import UNSET, UnsetType from autohotpy.proxies.ahk_object import AhkObject from autohotpy.proxies._sqr_brac_syntax import square_bracket_syntax @@ -28,12 +30,37 @@ def __getattr__(self, __name: str) -> Any: try: attr = super().__getattr__(__name) except exceptions.Error: - raise AttributeError( - f'Could not find global variable "{__name}" in ahk', - name=__name, - obj=self, - ) + if (lname := __name.lower()) != __name: + try: + attr = getattr(self, lname) + except AttributeError: + _not_found(self, __name) + else: + _not_found(self, __name) + if isinstance(attr, AhkObject) and attr._ahk_immortal: - # if the attr is immutable, cache the result + # if the attr is an immortal global (i.e. a function), cache the result in self self.__dict__[__name] = attr + self.__dict__[__name.lower()] = attr return attr + + def __dir__(self) -> set: + return {"include", "run_forever"} # TODO: fill this up! + + def __str__(self) -> str: + return "" + + def IsSet(self, value: Any = UNSET, /): + return value is not UNSET + + isset = IsSet + UNSET: UnsetType = UNSET + unset: UnsetType = UNSET + + +def _not_found(ahk, __name: str, /) -> NoReturn: + raise AttributeError( + f'No function, class, or global variable named "{__name}" exists', + name=__name, + obj=ahk, + ) from None diff --git a/autohotpy/static_typing/classes/__init__.pyi b/autohotpy/static_typing/classes/__init__.pyi index 899c69f..19e634b 100644 --- a/autohotpy/static_typing/classes/__init__.pyi +++ b/autohotpy/static_typing/classes/__init__.pyi @@ -1,4 +1,4 @@ -from typing import Literal, Self +from typing import Literal, Protocol, Self Nothing = Literal[""] @@ -25,3 +25,7 @@ MouseButton = Literal[ "WheelLeft", "WheelRight", ] + +class Prototype(Protocol): + __Class: str + base: Prototype | Nothing diff --git a/autohotpy/static_typing/classes/map.pyi b/autohotpy/static_typing/classes/map.pyi index 7c70d8f..0a8d6f2 100644 --- a/autohotpy/static_typing/classes/map.pyi +++ b/autohotpy/static_typing/classes/map.pyi @@ -1,12 +1,29 @@ -from typing import Literal, Self +from typing import Any, Literal, Self, overload, override from autohotpy.static_typing.classes import BoolInt, Nothing from autohotpy.static_typing.classes.func import Enumerator from autohotpy.static_typing.classes.protocols import DoubleIterable, SingleIterable class Map[KT, VT](SingleIterable[KT], DoubleIterable[KT, VT]): # TODO: docs?S - def __init__( - self, key1: KT = ..., value1: VT = ..., /, *other_keys_and_values: KT | VT - ): ... + @overload + def __new__[ + SelfT: Map + ]( + cls: type[SelfT], + key1: KT = ..., + value1: VT = ..., + /, + *other_keys_and_values: KT | VT, + ) -> SelfT: ... + @overload + def __new__( + cls: type[Self], + key1: KT = ..., + value1: VT = ..., + /, + *other_keys_and_values: KT | VT, + **kwargs: VT, + ) -> Map[str | KT, VT]: ... + def __init__(self, *args, **kwargs) -> None: ... def Clear(self) -> Nothing: ... def Clone(self) -> Self: ... def Delete(self, key: KT, /) -> VT: ... @@ -23,3 +40,5 @@ class Map[KT, VT](SingleIterable[KT], DoubleIterable[KT, VT]): # TODO: docs?S Default: VT __Item: Map[KT, VT] + + def __getitem__(self, item: KT) -> VT: ... diff --git a/autohotpy/static_typing/classes/object_.pyi b/autohotpy/static_typing/classes/object_.pyi index 8ca35d2..0a659bc 100644 --- a/autohotpy/static_typing/classes/object_.pyi +++ b/autohotpy/static_typing/classes/object_.pyi @@ -3,7 +3,9 @@ from typing import Any, Iterable, Self from autohotpy.static_typing.classes import BoolInt class Object: - def __init__(self): ... + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + def Clone(self) -> Self: """Returns a shallow copy of an object.""" diff --git a/pyproject.toml b/pyproject.toml index 3fab1f5..6ebf76e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ py-modules = ["autohotpy"] [tool.pytest.ini_options] pythonpath = "." +timeout = 30 [tool.pyright] include = [] diff --git a/tessst_ahp.py b/tessst_ahp.py index e4f0850..0478095 100644 --- a/tessst_ahp.py +++ b/tessst_ahp.py @@ -3,7 +3,6 @@ from time import perf_counter from autohotpy import ahk -import os # ahk.include(r"testit.ahk") @@ -31,6 +30,16 @@ class Wut: foo: dict +def import_typing(): + from autohotpy import ahk + + ahk["hi"] + + ahk.Array + + from autohotpy.ahk import VarRef + + def main(): # ahk["^q" :: ahk.ExitApp] # ahk["^h" :: ahk.caller] @@ -57,6 +66,8 @@ def main(): # print(f"caller returned {ahk.caller('weee')}") + ahk.MsgBox("hi") + ahk.run_forever() diff --git a/tests/test_autohotpy.ahk b/tests/test_autohotpy.ahk index f4fe6b1..b25dcf0 100644 --- a/tests/test_autohotpy.ahk +++ b/tests/test_autohotpy.ahk @@ -56,6 +56,8 @@ caller(ind) { return t_p } + + my_hoop := Hoopla() ; MsgBox JSON.Stringify([1, 1.0, [1, 2, 3], {a:'hello', b:thing}]) diff --git a/tests/test_autohotpy.py b/tests/test_autohotpy.py index b850d88..d1a42cc 100644 --- a/tests/test_autohotpy.py +++ b/tests/test_autohotpy.py @@ -1,4 +1,3 @@ -from autohotpy.ahk import Menu from autohotpy import ahk ahk.include(r"tests/test_autohotpy.ahk") @@ -14,3 +13,22 @@ def test_function(): n = ahk.abs(-1.0) assert type(n) == float assert n == 1.0 + + +def test_special(): + v = ahk.VarRef("shoo") + + assert v.value == "shoo" + + v.value = "well then" + + assert v.value == "well then" + + obj = ahk.Object(foo="bar", hoo="baz") + + assert obj.foo == "bar" and obj.hoo == "baz" + + obj = ahk.Map("wut", "that", foo="bar", hoo="baz") + + assert obj["wut"] == "that" + assert obj["foo"] == "bar" diff --git a/tests/test_errors.ahk b/tests/test_errors.ahk new file mode 100644 index 0000000..bd1f67c --- /dev/null +++ b/tests/test_errors.ahk @@ -0,0 +1,4 @@ + +call_me_back(fn, params*) { + return fn(params*) +} \ No newline at end of file diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..f691828 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,50 @@ +from typing import Callable +import pytest +from autohotpy import ahk +from autohotpy.communicator.references import set_debug + +set_debug(True) + +ahk.include("tests/test_errors.ahk") +f = ahk.call_me_back + + +thingy = object() + + +def _returns(): + return "hello" + + +def _returns_obj(): + return thingy + + +def _raises(): + raise ValueError("well darn") + + +def _generate_stack(stack_size: int, callback: Callable, args: tuple): + if stack_size > 0: + return f(_generate_stack, stack_size - 1, callback, args) + else: + return callback(*args) + + +def test_stack(): + + assert f(_returns) == "hello" + assert f(f, f, f, f, f, f, f, _returns) == "hello" + + assert f(_returns_obj) is thingy + + assert _generate_stack(10, _returns_obj, ()) is thingy + + +def test_raise(): + + with pytest.raises(ValueError): + f(_raises) + + with pytest.raises(ValueError): + _generate_stack(10, _raises, ()) diff --git a/tests/test_pickle.py b/tests/test_pickle.py index 77ab754..6fcbab0 100644 --- a/tests/test_pickle.py +++ b/tests/test_pickle.py @@ -1,5 +1,6 @@ from autohotpy import ahk from pickle import dumps, loads +import pytest as tst def test_obj(): diff --git a/tests/test_unset.py b/tests/test_unset.py new file mode 100644 index 0000000..eee9e0f --- /dev/null +++ b/tests/test_unset.py @@ -0,0 +1 @@ +# TODO: this