init commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
|
||||
def get_communication_dir_path(name: str) -> Path:
|
||||
"""Returns directory that is used by command-server for communication
|
||||
|
||||
Args:
|
||||
name (str): The name of the communication dir
|
||||
Returns:
|
||||
Path: The path to the communication dir
|
||||
"""
|
||||
suffix = ""
|
||||
|
||||
# NB: We don't suffix on Windows, because the temp dir is user-specific
|
||||
# anyways
|
||||
if hasattr(os, "getuid"):
|
||||
suffix = f"-{os.getuid()}"
|
||||
|
||||
return Path(gettempdir()) / f"{name}{suffix}"
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from talon import actions
|
||||
|
||||
# The amount of time to wait for application to perform a command, in seconds
|
||||
RPC_COMMAND_TIMEOUT_SECONDS = 3.0
|
||||
|
||||
# When doing exponential back off waiting for application to perform a command, how
|
||||
# long to sleep the first time
|
||||
MINIMUM_SLEEP_TIME_SECONDS = 0.0005
|
||||
|
||||
|
||||
def read_json_with_timeout(path: Path) -> Any:
|
||||
"""Repeatedly tries to read a json object from the given path, waiting
|
||||
until there is a trailing new line indicating that the write is complete
|
||||
|
||||
Args:
|
||||
path (str): The path to read from
|
||||
|
||||
Raises:
|
||||
Exception: If we timeout waiting for a response
|
||||
|
||||
Returns:
|
||||
Any: The json-decoded contents of the file
|
||||
"""
|
||||
timeout_time = time.perf_counter() + RPC_COMMAND_TIMEOUT_SECONDS
|
||||
sleep_time = MINIMUM_SLEEP_TIME_SECONDS
|
||||
while True:
|
||||
try:
|
||||
raw_text = path.read_text()
|
||||
|
||||
if raw_text.endswith("\n"):
|
||||
break
|
||||
except FileNotFoundError:
|
||||
# If not found, keep waiting
|
||||
pass
|
||||
|
||||
actions.sleep(sleep_time)
|
||||
|
||||
time_left = timeout_time - time.perf_counter()
|
||||
|
||||
if time_left < 0:
|
||||
raise Exception("Timed out waiting for response")
|
||||
|
||||
# NB: We use minimum sleep time here to ensure that we don't spin with
|
||||
# small sleeps due to clock slip
|
||||
sleep_time = max(min(sleep_time * 2, time_left), MINIMUM_SLEEP_TIME_SECONDS)
|
||||
|
||||
return json.loads(raw_text)
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def robust_unlink(path: Path):
|
||||
"""Unlink the given file if it exists, and if we're on windows and it is
|
||||
currently in use, just rename it
|
||||
|
||||
Args:
|
||||
path (Path): The path to unlink
|
||||
"""
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
if hasattr(e, "winerror") and e.winerror == 32:
|
||||
graveyard_dir = path.parent / "graveyard"
|
||||
graveyard_dir.mkdir(parents=True, exist_ok=True)
|
||||
graveyard_path = graveyard_dir / str(uuid4())
|
||||
print(
|
||||
f"WARNING: File {path} was in use when we tried to delete it; "
|
||||
f"moving to graveyard at path {graveyard_path}"
|
||||
)
|
||||
path.rename(graveyard_path)
|
||||
else:
|
||||
raise e
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from talon import Module, actions
|
||||
|
||||
from .get_communication_dir_path import get_communication_dir_path
|
||||
from .read_json_with_timeout import read_json_with_timeout
|
||||
from .robust_unlink import robust_unlink
|
||||
from .types import NoFileServerException, Request
|
||||
from .write_request import write_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mod = Module()
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
def rpc_client_run_command(
|
||||
dir_name: str,
|
||||
trigger_command_execution: Callable,
|
||||
command_id: str,
|
||||
args: list[Any],
|
||||
wait_for_finish: bool = False,
|
||||
return_command_output: bool = False,
|
||||
):
|
||||
"""Runs a command, using command server if available
|
||||
|
||||
Args:
|
||||
dir_name (str): The name of the directory to use for communication.
|
||||
trigger_command_execution (Callable): The function to call to trigger command execution.
|
||||
command_id (str): The ID of the command to run.
|
||||
args: The arguments to the command.
|
||||
wait_for_finish (bool, optional): Whether to wait for the command to finish before returning. Defaults to False.
|
||||
return_command_output (bool, optional): Whether to return the output of the command. Defaults to False.
|
||||
|
||||
Raises:
|
||||
Exception: If there is an issue with the file-based communication, or
|
||||
application raises an exception
|
||||
|
||||
Returns:
|
||||
Object: The response from the command, if requested.
|
||||
"""
|
||||
communication_dir_path = get_communication_dir_path(dir_name)
|
||||
|
||||
if not communication_dir_path.exists():
|
||||
logger.warning(
|
||||
f"Communication directory not found at: {communication_dir_path}"
|
||||
)
|
||||
if args or return_command_output:
|
||||
raise Exception(
|
||||
"Communication directory not found. Must use command-server extension for advanced commands"
|
||||
)
|
||||
raise NoFileServerException("Communication directory not found")
|
||||
|
||||
request_path = communication_dir_path / "request.json"
|
||||
response_path = communication_dir_path / "response.json"
|
||||
|
||||
# Generate uuid that will be mirrored back to us by command server for
|
||||
# sanity checking
|
||||
uuid = str(uuid4())
|
||||
|
||||
request = Request(
|
||||
command_id=command_id,
|
||||
args=args,
|
||||
wait_for_finish=wait_for_finish,
|
||||
return_command_output=return_command_output,
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
# First, write the request to the request file, which makes us the sole
|
||||
# owner because all other processes will try to open it with 'x'
|
||||
write_request(request, request_path)
|
||||
|
||||
# We clear the response file if it does exist, though it shouldn't
|
||||
if response_path.exists():
|
||||
print("WARNING: Found old response file")
|
||||
robust_unlink(response_path)
|
||||
|
||||
# Then, perform keystroke telling application to execute the command in the
|
||||
# request file. Because only the active application instance will accept
|
||||
# keypresses, we can be sure that the active application instance will be the
|
||||
# one to execute the command.
|
||||
trigger_command_execution()
|
||||
|
||||
try:
|
||||
decoded_contents = read_json_with_timeout(response_path)
|
||||
finally:
|
||||
# NB: We remove response file first because we want to do this while we
|
||||
# still own the request file
|
||||
robust_unlink(response_path)
|
||||
robust_unlink(request_path)
|
||||
|
||||
if decoded_contents["uuid"] != uuid:
|
||||
raise Exception("uuids did not match")
|
||||
|
||||
for warning in decoded_contents["warnings"]:
|
||||
print(f"WARNING: {warning}")
|
||||
|
||||
if decoded_contents["error"] is not None:
|
||||
raise Exception(decoded_contents["error"])
|
||||
|
||||
actions.sleep("25ms")
|
||||
|
||||
return decoded_contents["returnValue"]
|
||||
@@ -0,0 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
command_id: str
|
||||
args: list[Any]
|
||||
wait_for_finish: bool
|
||||
return_command_output: bool
|
||||
uuid: str
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"commandId": self.command_id,
|
||||
"args": self.args,
|
||||
"waitForFinish": self.wait_for_finish,
|
||||
"returnCommandOutput": self.return_command_output,
|
||||
"uuid": self.uuid,
|
||||
}
|
||||
|
||||
|
||||
class NoFileServerException(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .robust_unlink import robust_unlink
|
||||
from .types import Request
|
||||
|
||||
# How old a request file needs to be before we declare it stale and are willing
|
||||
# to remove it
|
||||
STALE_TIMEOUT_MS = 60_000
|
||||
|
||||
|
||||
def write_request(request: Request, path: Path):
|
||||
"""Converts the given request to json and writes it to the file, failing if
|
||||
the file already exists unless it is stale in which case it replaces it
|
||||
|
||||
Args:
|
||||
request (Request): The request to serialize
|
||||
path (Path): The path to write to
|
||||
|
||||
Raises:
|
||||
Exception: If another process has an active request file
|
||||
"""
|
||||
try:
|
||||
write_json_exclusive(path, request.to_dict())
|
||||
request_file_exists = False
|
||||
except FileExistsError:
|
||||
request_file_exists = True
|
||||
|
||||
if request_file_exists:
|
||||
handle_existing_request_file(path)
|
||||
write_json_exclusive(path, request.to_dict())
|
||||
|
||||
|
||||
def write_json_exclusive(path: Path, body: Any):
|
||||
"""Writes jsonified object to file, failing if the file already exists
|
||||
|
||||
Args:
|
||||
path (Path): The path of the file to write
|
||||
body (Any): The object to convert to json and write
|
||||
"""
|
||||
with path.open("x") as out_file:
|
||||
out_file.write(json.dumps(body))
|
||||
|
||||
|
||||
def handle_existing_request_file(path):
|
||||
stats = path.stat()
|
||||
|
||||
modified_time_ms = stats.st_mtime_ns / 1e6
|
||||
current_time_ms = time.time() * 1e3
|
||||
time_difference_ms = abs(modified_time_ms - current_time_ms)
|
||||
|
||||
if time_difference_ms < STALE_TIMEOUT_MS:
|
||||
raise Exception(
|
||||
"Found recent request file; another Talon process is probably running"
|
||||
)
|
||||
|
||||
print("Removing stale request file")
|
||||
robust_unlink(path)
|
||||
Reference in New Issue
Block a user