GP-5179: Improvements to pyghidra_launcher.py

* Specifying supported Python versions in application.properties
so other things can get access to it (similar to how we do it for Java
and Gradle supported versions)

* Only try to launch PyGhidra with a supported version of Python
This commit is contained in:
Ryan Kurtz 2024-12-09 14:22:54 -05:00
parent 18aa9a48f8
commit 6443e97b64
9 changed files with 128 additions and 55 deletions

View file

@ -1,12 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<?eclipse-pydev version="1.0"?><pydev_project>
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python interpreter</pydev_property>
<pydev_pathproperty name="org.python.pydev.PROJECT_SOURCE_PATH">
<path>/${PROJECT_DIR_NAME}/ghidra_scripts</path>
<path>/${PROJECT_DIR_NAME}/src/main/py/src/pyghidra</path>
<path>/${PROJECT_DIR_NAME}/support</path>
</pydev_pathproperty>
</pydev_project>

View file

@ -19,10 +19,9 @@ import os
import sys
import subprocess
import sysconfig
import venv
from pathlib import Path
from typing import List, Dict
from sys import stderr, version
from itertools import chain
from typing import List, Dict, Tuple
def get_application_properties(install_dir: Path) -> Dict[str, str]:
app_properties_path: Path = install_dir / 'Ghidra' / 'application.properties'
@ -37,12 +36,14 @@ def get_application_properties(install_dir: Path) -> Dict[str, str]:
props[key] = value
return props
def get_user_settings_dir(install_dir: Path) -> Path:
def get_user_settings_dir(install_dir: Path, dev: bool) -> Path:
props: Dict[str, str] = get_application_properties(install_dir)
app_name: str = props['application.name'].replace(' ', '').lower()
app_version: str = props['application.version']
app_release_name: str = props['application.release.name']
versioned_name: str = f'{app_name}_{app_version}_{app_release_name}'
if dev:
versioned_name += f'_location_{install_dir.parent.name}'
xdg_config_home: str = os.environ.get('XDG_CONFIG_HOME')
if xdg_config_home:
return Path(xdg_config_home) / app_name / versioned_name
@ -52,37 +53,62 @@ def get_user_settings_dir(install_dir: Path) -> Path:
return Path.home() / 'Library' / app_name / versioned_name
return Path.home() / '.config' / app_name / versioned_name
def find_supported_python_exe(install_dir: Path, dev: bool) -> List[str]:
python_cmds = []
saved_python_cmd = get_saved_python_cmd(install_dir, dev)
if saved_python_cmd is not None:
python_cmds.append(saved_python_cmd)
print("Last used Python executable: " + str(saved_python_cmd))
props: Dict[str, str] = get_application_properties(install_dir)
prop: str = 'application.python.supported'
supported: List[str] = [s.strip() for s in props.get(prop, '').split(',')]
if '' in supported:
raise ValueError(f'Invalid "{prop}" value in application.properties file')
python_cmds += list(chain.from_iterable([[f'python{s}'], ['py', f'-{s}']] for s in supported))
python_cmds += [['python3'], ['python'], ['py']]
for cmd in python_cmds:
try:
result = subprocess.run(cmd + ['-c', 'import sys; print("{0}.{1}".format(*sys.version_info))'], capture_output=True, text=True)
version = result.stdout.strip()
if result.returncode == 0 and version in supported:
return cmd
except FileNotFoundError:
pass
return None
def in_venv() -> bool:
return sys.prefix != sys.base_prefix
def is_externally_managed() -> bool:
marker: Path = Path(sysconfig.get_path("stdlib", sysconfig.get_default_scheme())) / 'EXTERNALLY-MANAGED'
marker: Path = Path(sysconfig.get_path('stdlib', sysconfig.get_default_scheme())) / 'EXTERNALLY-MANAGED'
return marker.is_file()
def get_venv_exe(venv_dir: Path) -> str:
def get_venv_exe(venv_dir: Path) -> List[str]:
win_python_cmd: str = str(venv_dir / 'Scripts' / 'python.exe')
linux_python_cmd: str = str(venv_dir / 'bin' / 'python3')
return win_python_cmd if platform.system() == 'Windows' else linux_python_cmd
return [win_python_cmd] if platform.system() == 'Windows' else [linux_python_cmd]
def get_ghidra_venv(install_dir: Path) -> Path:
user_settings_dir: Path = get_user_settings_dir(install_dir)
venv_dir: Path = user_settings_dir / 'venv'
return venv_dir
def get_ghidra_venv(install_dir: Path, dev: bool) -> Path:
return (install_dir / 'build' if dev else get_user_settings_dir(install_dir, dev)) / 'venv'
def create_ghidra_venv(venv_dir: Path) -> None:
print(f'Creating Ghidra virtual environemnt at {venv_dir}...')
venv.create(venv_dir, with_pip=True)
def create_ghidra_venv(python_cmd: List[str], venv_dir: Path) -> None:
print(f'Creating Ghidra virtual environment at {venv_dir}...')
subprocess.run(python_cmd + ['-m', 'venv', venv_dir.absolute()])
def version_tuple(v):
def version_tuple(v: str) -> Tuple[str, ...]:
filled = []
for point in v.split("."):
filled.append(point.zfill(8))
return tuple(filled)
def get_package_version(python_cmd: str, package: str) -> str:
def get_package_version(python_cmd: List[str], package: str) -> str:
version = None
result = subprocess.Popen([python_cmd, '-m', 'pip', 'show', package], stdout=subprocess.PIPE, text=True)
for line in result.stdout.readlines():
result = subprocess.run(python_cmd + ['-m', 'pip', 'show', package], capture_output=True, text=True)
for line in result.stdout.splitlines():
line = line.strip()
print(line)
key, value = line.split(':', 1)
@ -90,15 +116,33 @@ def get_package_version(python_cmd: str, package: str) -> str:
version = value.strip()
return version
def install(install_dir: Path, python_cmd: str, pip_args: List[str], offer_venv: bool) -> bool:
def get_saved_python_cmd(install_dir: Path, dev: bool) -> List[str]:
user_settings_dir: Path = get_user_settings_dir(install_dir, dev)
save_file: Path = user_settings_dir / 'python_command.save'
if not save_file.is_file():
return None
ret = []
with open(save_file, 'r') as f:
for line in f:
ret.append(line.strip())
return ret
def save_python_cmd(install_dir: Path, python_cmd: List[str], dev: bool) -> None:
user_settings_dir: Path = get_user_settings_dir(install_dir, dev)
save_file: Path = user_settings_dir / 'python_command.save'
with open(save_file, 'w') as f:
f.write('\n'.join(python_cmd) + '\n')
def install(install_dir: Path, python_cmd: List[str], pip_args: List[str], offer_venv: bool) -> List[str]:
install_choice: str = input('Do you wish to install PyGhidra (y/n)? ')
if install_choice.lower() in ('y', 'yes'):
if offer_venv:
ghidra_venv_choice: str = input('Install into new Ghidra virtual environment (y/n)? ')
if ghidra_venv_choice.lower() in ('y', 'yes'):
venv_dir = get_ghidra_venv(install_dir)
create_ghidra_venv(venv_dir)
venv_dir = get_ghidra_venv(install_dir, False)
create_ghidra_venv(python_cmd, venv_dir)
python_cmd = get_venv_exe(venv_dir)
print(f'Switching to Ghidra virtual environment: {venv_dir}')
elif ghidra_venv_choice.lower() in ('n', 'no'):
system_venv_choice: str = input('Install into system environment (y/n)? ')
if not system_venv_choice.lower() in ('y', 'yes'):
@ -107,13 +151,13 @@ def install(install_dir: Path, python_cmd: str, pip_args: List[str], offer_venv:
else:
print('Please answer yes or no.')
return None
subprocess.check_call([python_cmd] + pip_args)
subprocess.check_call(python_cmd + pip_args)
return python_cmd
elif not install_choice.lower() in ('n', 'no'):
print('Please answer yes or no.')
return None
def upgrade(python_cmd: str, pip_args: List[str], dist_dir: Path, current_pyghidra_version: str) -> bool:
def upgrade(python_cmd: List[str], pip_args: List[str], dist_dir: Path, current_pyghidra_version: str) -> bool:
included_pyghidra: Path = next(dist_dir.glob('pyghidra-*.whl'), None)
if included_pyghidra is None:
print('Warning: included pyghidra wheel was not found', file=sys.stderr)
@ -124,7 +168,7 @@ def upgrade(python_cmd: str, pip_args: List[str], dist_dir: Path, current_pyghid
choice: str = input(f'Do you wish to upgrade PyGhidra {current_version} to {included_version} (y/n)? ')
if choice.lower() in ('y', 'yes'):
pip_args.append('-U')
subprocess.check_call([python_cmd] + pip_args)
subprocess.check_call(python_cmd + pip_args)
return True
else:
print('Skipping upgrade')
@ -140,12 +184,17 @@ def main() -> None:
args, remaining = parser.parse_known_args()
# Setup variables
python_cmd: str = sys.executable
install_dir: Path = Path(args.install_dir)
install_dir: Path = Path(os.path.normpath(args.install_dir))
pyghidra_dir: Path = install_dir / 'Ghidra' / 'Features' / 'PyGhidra'
dist_dir: Path = pyghidra_dir / 'pypkg' / 'dist'
dev_venv_dir = install_dir / 'build' / 'venv'
release_venv_dir = get_ghidra_venv(install_dir)
venv_dir = get_ghidra_venv(install_dir, args.dev)
python_cmd: List[str] = find_supported_python_exe(install_dir, args.dev)
if python_cmd is not None:
print(f'Using Python command: "{" ".join(python_cmd)}"')
else:
print('Supported version of Python not found. Check application.properties file.')
sys.exit(1)
# If headless, force console mode
if args.headless:
@ -153,11 +202,12 @@ def main() -> None:
if args.dev:
# If in dev mode, launch PyGhidra from the source tree using the development virtual environment
if not dev_venv_dir.is_dir():
if not venv_dir.is_dir():
print('Virtual environment not found!')
print('Run "gradle prepdev" and try again.')
sys.exit(1)
python_cmd = get_venv_exe(dev_venv_dir)
python_cmd = get_venv_exe(venv_dir)
print(f'Switchiing to Ghidra virtual environment: {venv_dir}')
else:
# If in release mode, offer to install or upgrade PyGhidra before launching from user-controlled environment
pip_args: List[str] = ['-m', 'pip', 'install', '--no-index', '-f', str(dist_dir), 'pyghidra']
@ -170,14 +220,15 @@ def main() -> None:
if in_venv():
# If we are already in a virtual environment, assume that's where the user wants to be
print(f'Using active virtual environment: {sys.prefix}')
elif os.path.isdir(release_venv_dir):
elif os.path.isdir(venv_dir):
# If the Ghidra user settings venv exists, use that
python_cmd = get_venv_exe(release_venv_dir)
print(f'Using Ghidra virtual environment: {release_venv_dir}')
python_cmd = get_venv_exe(venv_dir)
print(f'Switching to Ghidra virtual environment: {venv_dir}')
elif is_externally_managed():
print('Externally managed environment detected!')
create_ghidra_venv(release_venv_dir)
python_cmd = get_venv_exe(release_venv_dir)
create_ghidra_venv(python_cmd, venv_dir)
python_cmd = get_venv_exe(venv_dir)
print(f'Switching to Ghidra virtual environment: {venv_dir}')
else:
offer_venv = True
@ -192,7 +243,8 @@ def main() -> None:
upgrade(python_cmd, pip_args, dist_dir, current_pyghidra_version)
# Launch PyGhidra
py_args: List[str] = [python_cmd, '-m', 'pyghidra.ghidra_launch', '--install-dir', str(install_dir)]
save_python_cmd(install_dir, python_cmd, args.dev)
py_args: List[str] = python_cmd + ['-m', 'pyghidra.ghidra_launch', '--install-dir', str(install_dir)]
if args.headless:
py_args += ['ghidra.app.util.headless.AnalyzeHeadless']
else:

View file

@ -95,6 +95,12 @@ public class ApplicationProperties extends Properties {
*/
public static final String APPLICATION_JAVA_COMPILER_PROPERTY = "application.java.compiler";
/**
* A comma-delimted priority-ordred list of versions of Python supported by the application.
*/
public static final String APPLICATION_PYTHON_SUPPORTED_PROPERTY =
"application.python.supported";
/**
* The date the application was built on, in a long format.
* For example, "2018-Jan-11 1346 EST".

View file

@ -7,3 +7,4 @@ application.gradle.max=
application.java.min=21
application.java.max=
application.java.compiler=21
application.python.supported=3.12, 3.11, 3.10, 3.9

View file

@ -49,7 +49,6 @@ if ("32".equals(System.getProperty("sun.arch.data.model"))) {
/***************************************************************************************
* Identify supported Python command
***************************************************************************************/
project.ext.SUPPORTED_PY_VERSIONS = ['3.12', '3.11', '3.10', '3.9']
project.ext.PYTHON3 = findPython3(true)
project.ext.PYTHON_DEPS = new HashSet<String>()
@ -219,12 +218,21 @@ def checkPip(List<String> pyCmd, boolean shouldPrint) {
}
def findPython3(boolean shouldPrint) {
def pyCmds = SUPPORTED_PY_VERSIONS.collectMany { [["python$it"], ["py", "-$it"]] }
pyCmds += [['py'], ['python3'], ['python']]
def supportedVersions = "${PYTHON_SUPPORTED}".split(",").collect {
try {
GradleVersion.version(it.trim()).getVersion() // use GradleVersion to validate version format
}
catch (IllegalArgumentException e) {
throw new GradleException("Invalid supported Python version list specified in application.properties.\n" + e.message);
}
}
def pyCmds = supportedVersions.collectMany { [["python$it"], ["py", "-$it"]] }
pyCmds += [['python3'], ['python'], ['py']]
for (pyCmd in pyCmds) {
def pyVer = checkPythonVersion(pyCmd)
def pyExe = getPythonExecutable(pyCmd)
if (pyVer in SUPPORTED_PY_VERSIONS) {
if (pyVer in supportedVersions) {
if (shouldPrint) {
println("Python3 command: ${pyCmd} (${pyVer}, ${pyExe})")
}
@ -234,7 +242,7 @@ def findPython3(boolean shouldPrint) {
}
if (shouldPrint) {
println("Warning: Supported Python ${SUPPORTED_PY_VERSIONS} not found (required for build)")
println("Warning: Supported Python [${PYTHON_SUPPORTED}] not found (required for build)")
}
// Don't fail until task execution. Just retun null, which can be gracefully handled later.

View file

@ -49,7 +49,7 @@ task buildPyPackage {
doLast {
if (rootProject.PYTHON3 == null) {
throw new GradleException("A supported version of Python ${SUPPORTED_PY_VERSIONS} was not found!")
throw new GradleException("A supported version of Python [${PYTHON_SUPPORTED}] was not found!")
}
File setuptools = project(":Debugger-rmi-trace").findPyDep(".")

View file

@ -271,7 +271,7 @@ task createGhidraStubsWheel {
File setuptools = project(":Debugger-rmi-trace").findPyDep(".")
if (PYTHON3 == null) {
throw new GradleException("A supported version of Python ${SUPPORTED_PY_VERSIONS} was not found!")
throw new GradleException("A supported version of Python [${PYTHON_SUPPORTED}] was not found!")
}
exec {

View file

@ -27,7 +27,7 @@ task createPythonVirtualEnvironment(type: Exec) {
doFirst {
if (rootProject.PYTHON3 == null) {
throw new GradleException("A supported version of Python ${SUPPORTED_PY_VERSIONS} was not found!")
throw new GradleException("A supported version of Python [${PYTHON_SUPPORTED}] was not found!")
}
commandLine rootProject.PYTHON3

View file

@ -28,6 +28,7 @@ file("Ghidra/application.properties").withReader { reader ->
project.ext.JAVA_COMPILER = ghidraProps.getProperty('application.java.compiler')
project.ext.GRADLE_MIN = ghidraProps.getProperty('application.gradle.min')
project.ext.GRADLE_MAX = ghidraProps.getProperty('application.gradle.max')
project.ext.PYTHON_SUPPORTED = ghidraProps.getProperty('application.python.supported')
project.ext.DISTRO_PREFIX = "ghidra_${version}_${RELEASE_NAME}"
// Build dates may or may not be already present in the application.properties file.