Skip to content

Instantly share code, notes, and snippets.

@hsupu
Created October 7, 2025 06:57
Show Gist options
  • Select an option

  • Save hsupu/feacdda135332d847bd5e3ccaa3ee351 to your computer and use it in GitHub Desktop.

Select an option

Save hsupu/feacdda135332d847bd5e3ccaa3ee351 to your computer and use it in GitHub Desktop.
Script to fix python venv exe after moved.
# 251004
# see https://stackoverflow.com/questions/35412392/how-can-i-use-setuptools-to-create-an-exe-launcher
from __future__ import annotations
from pip._vendor.distlib.scripts import ScriptMaker
import io
import logging
import os
import re
import sys
import zipfile
def main(args = None):
logger = logging.getLogger(__name__)
# Windows venv 不在 bin/ 子目录
bindir = os.path.join(sys.exec_prefix, "Scripts")
# logger.info(bindir)
regex_shabang = re.compile(r'^#!\s*(.+\\python(?:w)?\.exe)\s*$', re.MULTILINE)
regex_entrypoint = re.compile(r'^\s*from (\S+) import (\S+)\s*$', re.MULTILINE)
sm = ScriptMaker(
source_dir=None, # None to using entry spec instead
target_dir=bindir, # folder to put
add_launchers=True, # True to create .exe, False to create .py
)
pyexe = sys.executable
# while os.path.islink(pyexe):
# pyexe = os.path.realpath(pyexe)
# pyexe = os.path.abspath(pyexe)
# 对 Windows 而言 python pythonw 不同,后者没有下挂控制台窗口
sm.executable = pyexe
if sm.executable.endswith("pythonw.exe") > -1:
sm.executable = sm.executable.replace("pythonw", "python")
# create only the main variant (not the one with X.Y suffix)
sm.variants = [""]
for filename in os.listdir(bindir):
if not filename.endswith('.exe'):
continue
if filename in ('python.exe', 'pythonw.exe', 'python_d.exe', 'pythonw_d.exe'):
continue
fullname = os.path.join(bindir, filename)
logger.info(f'Open {fullname}')
with open(fullname, 'rb') as f:
# 解析 PE format,确定 overlay 位置
#
# 0:2 MZ
f.seek(0)
if f.read(2) != b'MZ':
logger.error(f'Not a MZ file: {filename}')
continue
# 0x3C:4 e_lfanew, offset to PE header
f.seek(0x3C)
pe_offset = int.from_bytes(f.read(4), 'little')
# 0:4 PE\0\0
f.seek(pe_offset)
if f.read(4) != b'PE\0\0':
logger.error(f'Not a PE file: {filename}')
continue
pe_offset += 4
# 2:2 NumberOfSections
f.seek(pe_offset + 2)
NumberOfSections = int.from_bytes(f.read(2), 'little', signed=False)
# 16:2 SizeOfOptionalHeader
f.seek(pe_offset + 16)
SizeOfOptionalHeader = int.from_bytes(f.read(2), 'little', signed=False)
# skip OptionalHeader
pe_offset += 20 + SizeOfOptionalHeader
# now at SectionTable
# each section 40 bytes
max_end = 0
for i in range(NumberOfSections):
# 16:4 SizeOfRawData
f.seek(pe_offset + i * 40 + 16)
SizeOfRawData = int.from_bytes(f.read(4), 'little', signed=False)
# 20:4 PointerToRawData
f.seek(pe_offset + i * 40 + 20)
PointerToRawData = int.from_bytes(f.read(4), 'little', signed=False)
end = PointerToRawData + SizeOfRawData
# logger.debug(f'Section {i}: {PointerToRawData:x} + {SizeOfRawData:x} = {end:x}')
if end > max_end:
max_end = end
logger.debug(f'Overlay offset: {max_end:x}')
f.seek(max_end)
# 目前有两种情况得到支持
header = f.read(2)
if header == b'PK':
def parse_250806():
"""
整体是 zip(__main__.py)
需要手动解压缩得到 __main__.py,该文件头部有 shabang python.exe
"""
f.seek(max_end)
zip_data = f.read()
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
with zf.open('__main__.py') as main_py:
content = main_py.read().decode()
match = regex_shabang.search(content)
if not match:
logger.error(f'shabang not matched our regex pattern, skipping: {filename} {content}')
return None
if match.group(1).casefold() == pyexe.casefold():
logger.debug(f'no need to update: {filename}')
return None
content = parse_250806()
elif header == b'#!':
def parse_220806():
"""
结构是 shabang python.exe + \n + zip(__main__.py)
运行 python 来解压缩得到 __main__.py
"""
shabang = '#!' + f.readline(1024).decode().rstrip()
match = regex_shabang.search(shabang)
if not match:
logger.error(f'shabang not matched our regex pattern, skipping: {filename} {shabang}')
return None
if match.group(1).casefold() == pyexe.casefold():
logger.debug(f'no need to update: {filename}')
return None
zip_data = f.read()
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
with zf.open('__main__.py') as main_py:
content = main_py.read().decode()
return content
content = parse_220806()
else:
logger.error(f'Unknown header, fix_entrypoints.py is outdated?: {filename} {header}')
continue
if content is None:
continue
logger.debug(content)
match = regex_entrypoint.search(content)
if not match:
logger.error(f'Entrypoint not matched. fix_entrypoints.py is outdated?: {filename}\n{content}')
return
basename, extname = os.path.basename(fullname).rsplit('.', maxsplit=1)
modulepath, varname = match.group(1), match.group(2)
spec = f"{basename} = {modulepath}:{varname}"
logger.warning(spec)
# return
# provide an entry specification string here, just like in pyproject.toml
sm.make(spec)
# return
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('-v', '--verbose', action='count', default=0)
args = parser.parse_args()
if args.verbose > 1:
logging.basicConfig(level=logging.DEBUG)
elif args.verbose > 0:
logging.basicConfig(level=logging.INFO)
else:
logging.basicConfig(level=logging.WARNING)
main(args)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment