384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""Module for the VersionInfo base class."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from importlib import import_module
|
|
from pathlib import Path
|
|
from struct import calcsize, pack
|
|
from typing import ClassVar
|
|
|
|
from packaging.version import Version
|
|
|
|
__all__ = ["VersionInfo"]
|
|
|
|
# types
|
|
CHAR = "c"
|
|
WCHAR = "ss"
|
|
WORD = "=H"
|
|
DWORD = "=L"
|
|
|
|
# constants
|
|
RT_VERSION = 16
|
|
ID_VERSION = 1
|
|
|
|
VS_FFI_SIGNATURE = 0xFEEF04BD
|
|
VS_FFI_STRUCVERSION = 0x00010000
|
|
VS_FFI_FILEFLAGSMASK = 0x0000003F
|
|
VOS_NT_WINDOWS32 = 0x00040004
|
|
|
|
KEY_VERSION_INFO = "VS_VERSION_INFO"
|
|
KEY_STRING_FILE_INFO = "StringFileInfo"
|
|
KEY_STRING_TABLE = "040904E4"
|
|
KEY_VAR_FILE_INFO = "VarFileInfo"
|
|
|
|
COMMENTS_MAX_LEN = (64 - 2) * 1024 // calcsize(WCHAR)
|
|
|
|
# To disable the experimental feature in Windows:
|
|
# set CX_FREEZE_STAMP=pywin32
|
|
# pip install -U pywin32
|
|
if os.environ.get("CX_FREEZE_STAMP", "") == "pywin32":
|
|
CX_FREEZE_STAMP = "pywin32"
|
|
else:
|
|
CX_FREEZE_STAMP = "internal"
|
|
|
|
|
|
class Structure:
|
|
"""Abstract base class for structures in native byte order. Concrete
|
|
structure and union types must be created by subclassing one of these
|
|
types, and at least define a _fields class variable.
|
|
"""
|
|
|
|
def __init__(self, *args) -> None:
|
|
if not hasattr(self, "_fields"):
|
|
self._fields: list[tuple[str, str]] = []
|
|
for i, (field, _) in enumerate(self._fields):
|
|
setattr(self, field, args[i])
|
|
|
|
def __str__(self) -> str:
|
|
dump = json.dumps(self.as_dict(), indent=2)
|
|
return self.__class__.__name__ + ": " + dump
|
|
|
|
def as_dict(self) -> dict[str, str]:
|
|
"""Return the field values as dictionary."""
|
|
fields = {}
|
|
for fieldname, _ in self._fields:
|
|
data = getattr(self, fieldname)
|
|
if hasattr(data, "as_dict"):
|
|
data = data.as_dict()
|
|
elif isinstance(data, bytes):
|
|
data = data.decode()
|
|
fields[fieldname] = data
|
|
return fields
|
|
|
|
def to_buffer(self) -> bytes:
|
|
"""Return the field values to a buffer."""
|
|
buffer = b""
|
|
for fieldname, fmt in self._fields:
|
|
data = getattr(self, fieldname)
|
|
if hasattr(data, "to_buffer"):
|
|
data = data.to_buffer()
|
|
elif isinstance(data, str):
|
|
data = data.encode("utf-16le")
|
|
elif isinstance(data, int):
|
|
data = pack(fmt, data)
|
|
buffer += data
|
|
return buffer
|
|
|
|
|
|
class VS_FIXEDFILEINFO(Structure):
|
|
"""Version information for a Win32 file."""
|
|
|
|
_fields: ClassVar[list[tuple[str, str]]] = [
|
|
("dwSignature", DWORD),
|
|
("dwStrucVersion", DWORD),
|
|
("dwFileVersionMS", DWORD),
|
|
("dwFileVersionLS", DWORD),
|
|
("dwProductVersionMS", DWORD),
|
|
("dwProductVersionLS", DWORD),
|
|
("dwFileFlagsMask", DWORD),
|
|
("dwFileFlags", DWORD),
|
|
("dwFileOS", DWORD),
|
|
("dwFileType", DWORD),
|
|
("dwFileSubtype", DWORD),
|
|
("dwFileDateMS", DWORD),
|
|
("dwFileDateLS", DWORD),
|
|
]
|
|
|
|
|
|
class String(Structure):
|
|
"""File version resource representation of the data."""
|
|
|
|
def __init__(
|
|
self, key: str, value: int | str | Structure | None = None
|
|
) -> None:
|
|
key = key + "\0"
|
|
key_len = len(key)
|
|
fields = [
|
|
("wLength", WORD),
|
|
("wValueLength", WORD),
|
|
("wType", WORD),
|
|
("szKey", WCHAR * key_len),
|
|
]
|
|
key_len = calcsize(WCHAR) * key_len
|
|
pad_len = (4 - ((calcsize(WORD) * 3 + key_len) & 3)) & 3
|
|
if 0 < pad_len < 4:
|
|
fields.append(("Padding", f"{pad_len}s"))
|
|
value_len = 0
|
|
value_type = 1
|
|
value_size = 1
|
|
if isinstance(value, int):
|
|
value_len = calcsize(DWORD)
|
|
value_type = 0
|
|
fields.append(("Value", DWORD))
|
|
elif isinstance(value, str):
|
|
value = value + "\0"
|
|
value_len = len(value)
|
|
value_size = calcsize(WCHAR)
|
|
fields.append(("Value", WCHAR * value_len))
|
|
elif hasattr(value, "wLength"): # instance of String
|
|
value_len = value.wLength
|
|
fields.append(("Value", type(value)))
|
|
elif isinstance(value, Structure):
|
|
value_len = 0
|
|
for field in value._fields:
|
|
value_len += calcsize(field[1])
|
|
value_type = 0
|
|
fields.append(("Value", type(value)))
|
|
|
|
self._fields = fields
|
|
self.wValueLength = value_len
|
|
self.wType = value_type
|
|
self.szKey = key
|
|
self.Padding = b"\0" * pad_len
|
|
self.Value = value
|
|
self.wLength = (
|
|
calcsize(WORD) * 3 + key_len + pad_len + value_size * value_len
|
|
)
|
|
self._children = 0
|
|
|
|
def children(self, value: String) -> None:
|
|
"""Represents the child String object."""
|
|
pad_len = 4 - (self.wLength & 3)
|
|
if 0 < pad_len < 4:
|
|
field = f"Padding{self._children}"
|
|
self._fields.append((field, f"{pad_len}s"))
|
|
setattr(self, field, b"\0" * pad_len)
|
|
self.wLength += calcsize(CHAR) * pad_len
|
|
field = f"Children{self._children}"
|
|
self._fields.append((field, type(value)))
|
|
setattr(self, field, value)
|
|
self._children += 1
|
|
self.wLength += value.wLength
|
|
|
|
|
|
class VersionInfo:
|
|
"""Organizes the version information (resource) data of a file."""
|
|
|
|
def __init__(
|
|
self,
|
|
version: str,
|
|
internal_name: str | None = None,
|
|
original_filename: str | None = None,
|
|
comments: str | None = None,
|
|
company: str | None = None,
|
|
description: str | None = None,
|
|
copyright: str | None = None, # noqa: A002
|
|
trademarks: str | None = None,
|
|
product: str | None = None,
|
|
dll: bool | None = None,
|
|
debug: bool | None = None,
|
|
verbose: bool = True,
|
|
) -> None:
|
|
valid_version = Version(version)
|
|
parts = list(valid_version.release)
|
|
while len(parts) < 4:
|
|
parts.append(0)
|
|
self.version: str = ".".join(map(str, parts))
|
|
self.valid_version: Version = valid_version
|
|
self.internal_name: str | None = internal_name
|
|
self.original_filename: str | None = original_filename
|
|
# comments length must be limited to 31kb
|
|
self.comments: str = comments[:COMMENTS_MAX_LEN] if comments else None
|
|
self.company: str | None = company
|
|
self.description: str | None = description
|
|
self.copyright: str | None = copyright
|
|
self.trademarks: str | None = trademarks
|
|
self.product: str | None = product
|
|
self.dll: bool | None = dll
|
|
self.debug: bool | None = debug
|
|
self.verbose: bool = verbose
|
|
|
|
def stamp(self, path: str | Path) -> None:
|
|
"""Stamp a Win32 binary with version information."""
|
|
if isinstance(path, str):
|
|
path = Path(path)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(path)
|
|
|
|
if CX_FREEZE_STAMP == "pywin32":
|
|
try:
|
|
version_stamp = import_module("win32verstamp").stamp
|
|
except ImportError as exc:
|
|
msg = "install pywin32 extension first"
|
|
raise RuntimeError(msg) from exc
|
|
# comments length must be limited to 15kb (uses WORD='h')
|
|
self.comments = (self.comments or "")[: COMMENTS_MAX_LEN // 2]
|
|
version_stamp(os.fspath(path), self)
|
|
return
|
|
|
|
# internal
|
|
string_version_info = self.version_info(path)
|
|
if CX_FREEZE_STAMP == "internal":
|
|
try:
|
|
util = import_module("cx_Freeze.util")
|
|
except ImportError as exc:
|
|
msg = "cx_Freeze.util extension not found"
|
|
raise RuntimeError(msg) from exc
|
|
handle = util.BeginUpdateResource(path, 0)
|
|
util.UpdateResource(
|
|
handle, RT_VERSION, ID_VERSION, string_version_info.to_buffer()
|
|
)
|
|
util.EndUpdateResource(handle, 0)
|
|
|
|
if self.verbose:
|
|
print("Stamped:", path)
|
|
|
|
def version_info(self, path: Path) -> String:
|
|
"""Returns the String version info used to stamp the version."""
|
|
major = self.valid_version.major
|
|
minor = self.valid_version.minor
|
|
micro = self.valid_version.micro
|
|
build = 0
|
|
file_flags = 0
|
|
if self.debug is None or path.stem.lower().endswith("_d"):
|
|
file_flags += 1
|
|
if self.valid_version.is_devrelease:
|
|
file_flags += 8
|
|
build = self.valid_version.dev
|
|
elif self.valid_version.is_prerelease:
|
|
file_flags += 2
|
|
build = self.valid_version.pre[1]
|
|
elif self.valid_version.is_postrelease:
|
|
file_flags += 0x20
|
|
build = self.valid_version.post
|
|
elif len(self.valid_version.release) >= 4:
|
|
build = self.valid_version.release[3]
|
|
|
|
# use the data in the order shown in 'pepper'
|
|
data = {
|
|
"FileDescription": self.description or "",
|
|
"FileVersion": self.version,
|
|
"InternalName": self.internal_name or path.name,
|
|
"CompanyName": self.company or "",
|
|
"LegalCopyright": self.copyright or "",
|
|
"LegalTrademarks": self.trademarks or "",
|
|
"OriginalFilename": self.original_filename or path.name,
|
|
"ProductName": self.product or "",
|
|
"ProductVersion": str(self.valid_version),
|
|
"Comments": self.comments or "",
|
|
}
|
|
is_dll = self.dll
|
|
if is_dll is None:
|
|
is_dll = path.suffix.lower() in (".dll", ".pyd")
|
|
fixed_file_info = VS_FIXEDFILEINFO(
|
|
VS_FFI_SIGNATURE,
|
|
VS_FFI_STRUCVERSION,
|
|
(major << 16) | minor,
|
|
(micro << 16) | build,
|
|
(major << 16) | minor,
|
|
(micro << 16) | build,
|
|
VS_FFI_FILEFLAGSMASK,
|
|
file_flags,
|
|
VOS_NT_WINDOWS32,
|
|
2 if is_dll else 1, # VFT_DLL or VFT_APP
|
|
0,
|
|
0,
|
|
0,
|
|
)
|
|
|
|
# string table with its children
|
|
string_table = String(KEY_STRING_TABLE)
|
|
for key, value in data.items():
|
|
string_table.children(String(key, value))
|
|
|
|
# create string file info and add string table as child
|
|
string_file_info = String(KEY_STRING_FILE_INFO)
|
|
string_file_info.children(string_table)
|
|
|
|
# var file info has a child
|
|
var_file_info = String(KEY_VAR_FILE_INFO)
|
|
var_file_info.children(String("Translation", 0x04E40409)) # 0x409,1252
|
|
|
|
# VS_VERSION_INFO is the first key and has two children
|
|
string_version_info = String(KEY_VERSION_INFO, fixed_file_info)
|
|
string_version_info.children(string_file_info)
|
|
string_version_info.children(var_file_info)
|
|
|
|
return string_version_info
|
|
|
|
|
|
def main_test(args=None) -> None:
|
|
"""Command line test."""
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"filename",
|
|
nargs="?",
|
|
metavar="FILENAME",
|
|
help="the name of the file (.dll, .pyd or .exe) to test version stamp",
|
|
)
|
|
parser.add_argument(
|
|
"--version",
|
|
action="store",
|
|
default="0.1",
|
|
help="version to set as test",
|
|
)
|
|
parser.add_argument(
|
|
"--dict",
|
|
action="store_true",
|
|
dest="as_dict",
|
|
help="show version info as dict",
|
|
)
|
|
parser.add_argument(
|
|
"--raw",
|
|
action="store_true",
|
|
dest="as_raw",
|
|
help="show version info as raw bytes",
|
|
)
|
|
parser.add_argument(
|
|
"--pywin32",
|
|
action="store_true",
|
|
help="use pywin32 (win32verstamp) to set version information",
|
|
)
|
|
test_args = parser.parse_args(args)
|
|
if test_args.filename is None:
|
|
parser.error("filename must be specified")
|
|
else:
|
|
test_filename = Path(test_args.filename)
|
|
if test_args.pywin32:
|
|
global CX_FREEZE_STAMP # noqa: PLW0603
|
|
CX_FREEZE_STAMP = "pywin32"
|
|
|
|
test_version = VersionInfo(
|
|
test_args.version,
|
|
comments="cx_Freeze comments",
|
|
description="cx_Freeze description",
|
|
company="cx_Freeze company",
|
|
product="cx_Freeze product",
|
|
copyright="(c) 2024, cx_Freeze",
|
|
trademarks="cx_Freeze (TM)",
|
|
)
|
|
|
|
if test_args.as_dict:
|
|
print(test_version.version_info(test_filename))
|
|
if test_args.as_raw:
|
|
print(test_version.version_info(test_filename).to_buffer().hex(":"))
|
|
test_version.stamp(test_filename)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# simple test
|
|
main_test()
|