Skip to content

New runner #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 104 additions & 35 deletions pytinytex/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
import asyncio
from pathlib import Path
import sys
import os
import subprocess
import platform
import logging

from .tinytex_download import download_tinytex, DEFAULT_TARGET_FOLDER # noqa

logger = logging.getLogger("pytinytex")
formatter = logging.Formatter('%(message)s')

# create a console handler and set the level to debug
ch = logging.StreamHandler()
ch.setFormatter(formatter)
logger.addHandler(ch)



# Global cache
__tinytex_path = None

def update(package="-all"):
path = get_tinytex_path()
try:
code, stdout,stderr = _run_tlmgr_command(["update", package], path, False)
return True
except RuntimeError:
raise
return False
return _run_tlmgr_command(["update", package], path, False)

def help():
path = get_tinytex_path()
return _run_tlmgr_command(["help"], path, False)

def shell():
path = get_tinytex_path()
return _run_tlmgr_command(["shell"], path, False, True)

def get_tinytex_path(base=None):
if __tinytex_path:
Expand Down Expand Up @@ -74,41 +88,96 @@ def _get_file(dir, prefix):
except FileNotFoundError:
raise RuntimeError("Unable to find {}.".format(prefix))

def _run_tlmgr_command(args, path, machine_readable=True):
def _run_tlmgr_command(args, path, machine_readable=True, interactive=False):
if machine_readable:
if "--machine-readable" not in args:
args.insert(0, "--machine-readable")
tlmgr_executable = _get_file(path, "tlmgr")
# resolve any symlinks
tlmgr_executable = str(Path(tlmgr_executable).resolve(True))
args.insert(0, tlmgr_executable)
new_env = os.environ.copy()
creation_flag = 0x08000000 if sys.platform == "win32" else 0 # set creation flag to not open TinyTeX in new console on windows
p = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=new_env,
creationflags=creation_flag)
# something else than 'None' indicates that the process already terminated
if p.returncode is not None:
raise RuntimeError(
'TLMGR died with exitcode "%s" before receiving input: %s' % (p.returncode,
p.stderr.read())
)

stdout, stderr = p.communicate()


try:
stdout = stdout.decode("utf-8")
except UnicodeDecodeError:
raise RuntimeError("Unable to decode stdout from TinyTeX")

logger.debug(f"Running command: {args}")
return asyncio.run(_run_command(*args, stdin=interactive, env=new_env, creationflags=creation_flag))
except Exception:
raise

async def read_stdout(process, output_buffer):
"""Read lines from process.stdout and print them."""
logger.debug(f"Reading stdout from process {process.pid}")
try:
while True:
line = await process.stdout.readline()
if not line: # EOF reached
break
line = line.decode('utf-8').rstrip()
output_buffer.append(line)
logger.info(line)
except Exception as e:
logger.error(f"Error in read_stdout: {e}")
finally:
process._transport.close()
return await process.wait()

async def send_stdin(process):
"""Read user input from sys.stdin and send it to process.stdin."""
logger.debug(f"Sending stdin to process {process.pid}")
loop = asyncio.get_running_loop()
try:
stderr = stderr.decode("utf-8")
except UnicodeDecodeError:
raise RuntimeError("Unable to decode stderr from TinyTeX")
while True:
# Offload the blocking sys.stdin.readline() call to the executor.
user_input = await loop.run_in_executor(None, sys.stdin.readline)
if not user_input: # EOF (e.g. Ctrl-D)
break
process.stdin.write(user_input.encode('utf-8'))
await process.stdin.drain()
except Exception as e:
logger.error(f"Error in send_stdin: {e}")
finally:
if process.stdin:
process._transport.close()


async def _run_command(*args, stdin=False, **kwargs):
# Create the subprocess with pipes for stdout and stdin.
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE if stdin else asyncio.subprocess.DEVNULL,
**kwargs
)

output_buffer = []
# Create tasks to read stdout and send stdin concurrently.
stdout_task = asyncio.create_task(read_stdout(process, output_buffer))
stdin_task = None
if stdin:
stdin_task = asyncio.create_task(send_stdin(process))

if stderr == "" and p.returncode == 0:
return p.returncode, stdout, stderr
else:
raise RuntimeError("TLMGR died with the following error:\n{0}".format(stderr.strip()))
return p.returncode, stdout, stderr
try:
if stdin:
# Wait for both tasks to complete.
logger.debug("Waiting for stdout and stdin tasks to complete")
await asyncio.gather(stdout_task, stdin_task)
else:
# Wait for the stdout task to complete.
logger.debug("Waiting for stdout task to complete")
await stdout_task
# Return the process return code.
exit_code = await process.wait()
except KeyboardInterrupt:
process.terminate() # Gracefully terminate the subprocess.
exit_code = await process.wait()
finally:
# Cancel tasks that are still running.
stdout_task.cancel()
if stdin_task:
stdin_task.cancel()
captured_output = "\n".join(output_buffer)
if exit_code != 0:
raise RuntimeError(f"Error running command: {captured_output}")
return exit_code, captured_output
8 changes: 8 additions & 0 deletions tests/test_tinytex_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import pytinytex
from .utils import download_tinytex, TINYTEX_DISTRIBUTION # noqa

def test_run_help(download_tinytex): # noqa
exit_code, output = pytinytex.help()
assert exit_code == 0
assert "TeX Live" in output

Loading