diff --git a/pex/commands/command.py b/pex/commands/command.py index 22d61931f..962bbc8ec 100644 --- a/pex/commands/command.py +++ b/pex/commands/command.py @@ -19,7 +19,7 @@ from pex.cache import access as cache_access from pex.common import environment_as, safe_mkdtemp, safe_open from pex.compatibility import shlex_quote -from pex.os import LINUX +from pex.os import MAC, WINDOWS from pex.result import Error, Ok, Result from pex.subprocess import subprocess_daemon_kwargs from pex.typing import TYPE_CHECKING, Generic, cast @@ -52,7 +52,7 @@ def try_run_program( program, # type: str args, # type: Iterable[str] - url=None, # type: Optional[str] + program_info_url=None, # type: Optional[str] error=None, # type: Optional[str] disown=False, # type: bool **kwargs # type: Any @@ -76,35 +76,39 @@ def try_run_program( except OSError as e: msg = [error] if error else [] msg.append("Do you have `{}` installed on the $PATH?: {}".format(program, e)) - if url: + if program_info_url: msg.append( - "Find more information on `{program}` at {url}.".format(program=program, url=url) + "Find more information on `{program}` at {url}.".format( + program=program, url=program_info_url + ) ) return Error("\n".join(msg)) -def try_open_file( - path, # type: str +def try_open( + path_or_url, # type: str open_program=None, # type: Optional[str] error=None, # type: Optional[str] suppress_stderr=False, # type: bool ): # type: (...) -> Result - url = None # type: Optional[str] + program_info_url = None # type: Optional[str] if open_program: opener = open_program - elif LINUX: - opener = "xdg-open" - url = "https://www.freedesktop.org/wiki/Software/xdg-utils/" - else: + elif WINDOWS: + opener = "explorer" + elif MAC: opener = "open" + else: + opener = "xdg-open" + program_info_url = "https://www.freedesktop.org/wiki/Software/xdg-utils/" with open(os.devnull, "wb") as devnull: return try_run_program( opener, - [path], - url=url, + [path_or_url], + program_info_url=program_info_url, error=error, disown=True, stdout=devnull, diff --git a/pex/docs/command.py b/pex/docs/command.py index 9a7609edb..42d85e643 100644 --- a/pex/docs/command.py +++ b/pex/docs/command.py @@ -8,7 +8,7 @@ from textwrap import dedent from pex import docs -from pex.commands.command import try_open_file +from pex.commands.command import try_open from pex.docs.server import SERVER_NAME, LaunchError, LaunchResult from pex.docs.server import launch as launch_docs_server from pex.result import Error, try_ @@ -91,8 +91,6 @@ def serve_html_docs( return Error("Failed to launch {server}.".format(server=SERVER_NAME)) if open_browser: - try_( - try_open_file(result.server_info.url, open_program=config.browser, suppress_stderr=True) - ) + try_(try_open(result.server_info.url, open_program=config.browser, suppress_stderr=True)) return result diff --git a/pex/docs/server.py b/pex/docs/server.py index 47ff14766..af266b32c 100644 --- a/pex/docs/server.py +++ b/pex/docs/server.py @@ -1,7 +1,6 @@ # Copyright 2024 Pex project contributors. # Licensed under the Apache License, Version 2.0 (see LICENSE). -import errno import json import logging import os @@ -13,7 +12,7 @@ from pex.cache.dirs import CacheDir from pex.common import safe_open -from pex.os import kill +from pex.os import is_alive, kill from pex.subprocess import launch_python_daemon from pex.typing import TYPE_CHECKING from pex.version import __version__ @@ -79,7 +78,7 @@ def _read_url( with open(server_log) as fp: for line in fp: if line.endswith(("\r", "\n")): - match = re.search(r"Serving HTTP on 0\.0\.0\.0 port (?P\d+)", line) + match = re.search(r"Serving HTTP on \S+ port (?P\d+)", line) if match: port = match.group("port") return "http://localhost:{port}".format(port=port) @@ -106,13 +105,7 @@ def record( def alive(self): # type: () -> bool # TODO(John Sirois): Handle pid rollover - try: - os.kill(self.server_info.pid, 0) - return True - except OSError as e: - if e.errno == errno.ESRCH: # No such process. - return False - raise + return is_alive(self.server_info.pid) def kill(self): # type: () -> None diff --git a/pex/os.py b/pex/os.py index a18651592..43b6af317 100644 --- a/pex/os.py +++ b/pex/os.py @@ -120,6 +120,49 @@ def is_exe(path): if WINDOWS: + def is_alive(pid): + # type: (int) -> bool + + # TODO(John Sirois): This is extremely hacky, consider adding a psutil dependency for + # Windows. See: https://github.com/pex-tool/pex/issues/2699 + + import csv + import subprocess + + args = ["tasklist", "/FI", "PID eq {pid}".format(pid=pid), "/FO", "CSV"] + process = subprocess.Popen(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if process.returncode != 0: + raise RuntimeError( + "Failed to query status of process with pid {pid}.\n" + "Execution of `{args}` returned exit code {returncode}.\n" + "{stderr}".format( + pid=pid, + args=" ".join(args), + returncode=process.returncode, + stderr=stderr.decode("utf-8"), + ) + ) + + output = stdout.decode("utf-8") + if "No tasks are running" in output: + return False + + lines = output.splitlines() + if len(lines) != 2: + return False + + csv_reader = csv.DictReader(lines) + for row in csv_reader: + pid_value = row.get("PID", -1) + if pid_value == -1: + return False + try: + return pid == int(pid_value) + except (ValueError, TypeError): + return False + return False + # https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights _PROCESS_TERMINATE = 0x1 # Required to terminate a process using TerminateProcess. @@ -170,6 +213,19 @@ def kill(pid): else: + def is_alive(pid): + # type: (int) -> bool + + import errno + + try: + os.kill(pid, 0) + return True + except OSError as e: + if e.errno == errno.ESRCH: # No such process. + return False + raise + def kill(pid): # type: (int) -> None diff --git a/pex/tools/commands/graph.py b/pex/tools/commands/graph.py index 2fd7b8242..27244d025 100644 --- a/pex/tools/commands/graph.py +++ b/pex/tools/commands/graph.py @@ -10,7 +10,7 @@ from argparse import ArgumentParser from contextlib import contextmanager -from pex.commands.command import OutputMixin, try_open_file, try_run_program +from pex.commands.command import OutputMixin, try_open, try_run_program from pex.common import safe_mkdir from pex.dist_metadata import requires_dists from pex.interpreter_constraints import InterpreterConstraint @@ -124,7 +124,7 @@ def emit(): try: return try_run_program( "dot", - url="https://graphviz.org/", + program_info_url="https://graphviz.org/", error="Failed to render dependency graph for {}.".format(graph.name), args=["-T", self.options.format], stdin=read_fd, @@ -168,7 +168,7 @@ def run(self, pex): if result.is_error: return result - return try_open_file( + return try_open( open_path, error="Failed to open dependency graph of {} rendered in {} for viewing.".format( pex.path(), open_path