"""Assemble the offline Win7 source kit from verified downloads in a local cache.""" import argparse from datetime import datetime import hashlib import json from pathlib import Path import shutil import subprocess import zipfile REPO = Path(__file__).resolve().parents[2] HERE = Path(__file__).resolve().parent def assemble(cache, output): cache, output = cache.resolve(), output.resolve() if REPO not in output.parents: raise ValueError('Output must be within this workspace') if output.exists() or output.with_suffix('.zip').exists(): raise ValueError('Choose a new output directory; existing packages are not overwritten') # Only committed application files are shipped, never runtime directories. subprocess.run(['git', 'diff', '--exit-code', 'HEAD', '--', 'po_app'], cwd=REPO, check=True, stdout=subprocess.DEVNULL) commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() names = subprocess.check_output(['git', 'ls-files', '-z', 'po_app'], cwd=REPO).decode('utf-8').split('\0') output.mkdir(parents=True) def copy(source, relative): dest = output/relative dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, dest) for name in names: if not name: continue if name.endswith('.py') or name.startswith(('po_app/templates/', 'po_app/static/')): copy(REPO/name, name) for name in ['build.py', 'selftest.py', 'probe_exe.py', 'requirements-win7.txt', 'make_waitress_wheel.py', 'install_env.ps1']: copy(HERE/name, 'win7/' + name) for name in ['00_check_environment.cmd', '01_install_env.cmd', '02_build_exe.cmd']: (output/name).write_bytes((HERE/name).read_text(encoding='utf-8').replace('\r\n', '\n').replace('\n', '\r\n').encode('ascii')) (output/'README_WIN7.txt').write_text((HERE/'README_WIN7.md').read_text(encoding='utf-8'), encoding='utf-8-sig') copy(REPO/'output/pdf/博特维采购订单系统_v4_使用与升级手册.pdf', 'manual/博特维采购订单系统_v4_使用与升级手册.pdf') for sample in ('采购单_签章调整样张.pdf', '委外订单_签章调整样张.pdf'): copy(REPO/'output/pdf'/sample, 'samples/' + sample) for name, expected_md5 in [('python-3.8.10-amd64.exe', '62cf1a12a5276b0259e8761d4cf4fe42'), ('python-3.8.10.exe', 'b355cfc84b681ace8908ae50908e8761')]: if hashlib.md5((cache/name).read_bytes()).hexdigest() != expected_md5: raise ValueError('Installer differs from python.org download: ' + name) copy(cache/name, 'installers/' + name) pins = {} for line in (HERE/'requirements-win7.txt').read_text().splitlines(): if line and not line.startswith('#'): name, version = line.split('==') pins[name.lower().replace('-', '_')] = version for arch, folder in [('x64', 'wheels64'), ('x86', 'wheels32')]: selected = [] for wheel in (cache/folder).glob('*.whl'): name, version = wheel.name.split('-')[:2] if pins.get(name.lower()) == version: copy(wheel, 'wheels/' + arch + '/' + wheel.name) selected.append(name.lower()) if set(selected) != set(pins) or len(selected) != len(pins): raise ValueError('Incomplete wheel set: %s, missing %s' % (arch, set(pins)-set(selected))) copy(cache/'waitress-3.0.2-py3-none-any.whl', 'third_party/waitress-3.0.2-py3-none-any.whl') info = {'source_commit': commit, 'created_at': datetime.now().isoformat(timespec='seconds'), 'setup_revision': '20260917-reuse-installed-python', 'purpose': 'Build v4 on the target Windows 7 SP1 server', 'architectures': ['x86', 'x64'], 'python': '3.8.10', 'windows7_field_verified': False, 'validation_note': 'See VALIDATION.txt for local tests; target Win7 acceptance is still required.'} (output/'PACKAGE_INFO.json').write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding='utf-8') if (cache/'VALIDATION.txt').exists(): copy(cache/'VALIDATION.txt', 'VALIDATION.txt') hashes = {p.relative_to(output).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(output.rglob('*')) if p.is_file()} (output/'SHA256SUMS.json').write_text(json.dumps(hashes, ensure_ascii=False, indent=2), encoding='utf-8') archive = output.with_suffix('.zip') with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as zipout: for path in sorted(output.rglob('*')): if path.is_file(): zipout.write(path, (Path(output.name)/path.relative_to(output)).as_posix()) with zipfile.ZipFile(archive) as zipout: assert zipout.testzip() is None assert all(zipout.read(output.name + '/' + name) == (output/name).read_bytes() for name in hashes) digest = hashlib.sha256(archive.read_bytes()).hexdigest() archive.with_suffix('.zip.sha256.txt').write_text(digest + ' ' + archive.name + '\n', encoding='ascii') print(archive) print('Bytes:', archive.stat().st_size, 'SHA256:', digest) return archive if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--cache', type=Path, default=REPO/'tools/out/win7-prep') parser.add_argument('--output', type=Path, required=True) args = parser.parse_args() assemble(args.cache, args.output)