新增Win7离线构建并调整PDF采购章及供需方签章位置
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
set "PYTHONUTF8=1"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
set "PYTHONHOME="
|
||||
set "PYTHONPATH="
|
||||
set "PO_PYTHON=%~dp0.python38\python.exe"
|
||||
set "PO_INSTALLER=%~dp0installers\python-3.8.10.exe"
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" set "PO_INSTALLER=%~dp0installers\python-3.8.10-amd64.exe"
|
||||
if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" set "PO_INSTALLER=%~dp0installers\python-3.8.10-amd64.exe"
|
||||
if exist "%PO_PYTHON%" goto ready
|
||||
echo Installing the bundled Python 3.8.10. Windows 7 SP1 is required.
|
||||
echo If Windows requests a restart, restart and run this script again.
|
||||
start /wait "" "%PO_INSTALLER%" /passive InstallAllUsers=0 TargetDir="%~dp0.python38" Include_launcher=0 InstallLauncherAllUsers=0 PrependPath=0 Include_doc=0 Include_test=0 Include_tcltk=0 Shortcuts=0 AssociateFiles=0
|
||||
if errorlevel 1 goto failed
|
||||
:ready
|
||||
if not exist "%PO_PYTHON%" goto failed
|
||||
"%PO_PYTHON%" "%~dp0win7\build.py" setup
|
||||
if errorlevel 1 goto failed
|
||||
echo.
|
||||
echo Setup passed. Run 02_build_exe.cmd next.
|
||||
pause
|
||||
exit /b 0
|
||||
:failed
|
||||
echo.
|
||||
echo SETUP FAILED. See README_WIN7.txt and the message above.
|
||||
echo Do not install the modern Python 3.11 build on Windows 7.
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
set "PYTHONUTF8=1"
|
||||
set "PYTHONIOENCODING=utf-8"
|
||||
set "PYTHONHOME="
|
||||
set "PYTHONPATH="
|
||||
if not exist "%~dp0.buildenv\Scripts\python.exe" goto missing
|
||||
"%~dp0.buildenv\Scripts\python.exe" "%~dp0win7\build.py" build
|
||||
if errorlevel 1 goto failed
|
||||
echo.
|
||||
echo EXE is in the new dated subfolder under dist. See BUILD_INFO.json.
|
||||
pause
|
||||
exit /b 0
|
||||
:missing
|
||||
echo Run 01_install_env.cmd successfully first.
|
||||
:failed
|
||||
echo BUILD FAILED. Check the messages and build log. Keep the old server unchanged.
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,67 @@
|
||||
# Windows 7 服务器离线打包说明
|
||||
|
||||
本包用于在你的 Windows 7 服务电脑上,从 v4 源码生成 EXE。包括 Python 3.8.10 官方完整安装程序、32 位与 64 位离线依赖、构建脚本、环境与业务自检,以及现有 16 页使用升级手册。
|
||||
|
||||
2026-09-17 排版更新:采购专用章直径增加 30%,固定在右侧需方签章空位;供方和需方签章区之间留出 20 毫米间隔。采购单与委外订单均已调整,示例见 `samples/`。本次 PDF 模板版本为 `po-v4`。
|
||||
|
||||
升级后新审批生成的正式 PDF 使用新排版。已审批归档的 PDF 保留原版;需要采用新版的订单,请通过系统的“变更重审”流程重新审批。使用本包重新解压、安装环境并生成 EXE 后,按下文备份和替换服务程序。
|
||||
|
||||
## 1. 先确认系统
|
||||
|
||||
- 在“计算机”上点右键 → “属性”,确认是 Windows 7 **Service Pack 1**,并查看系统类型是 32 位还是 64 位。脚本会自动按系统类型选择对应安装程序和依赖。
|
||||
- Python 3.8 需要 Windows 7 的 KB2533623 或替代更新(如 KB3063858)所提供的 DLL 加载支持,以及 Universal C Runtime。安装 Python 时若提示系统补丁、运行库或重启,请先按提示补齐。补丁可能需要网管从微软获取,本包不自动更改系统补丁或防火墙。
|
||||
- 建议把整个压缩包解压到较短的固定路径,例如 `C:\PO_Win7Build`。不要在压缩软件内直接运行脚本,也不要放到正在保存正式订单的目录中。
|
||||
- 本包按 Win7 兼容依赖制作,当前电脑上的验证不等同于真实 Win7 验收;在目标服务器打包和自检通过后再投入使用。Win7 与 Python 3.8 已结束官方维护,按公司局域网范围部署。
|
||||
|
||||
## 2. 两步生成 EXE
|
||||
|
||||
1. 双击 `01_install_env.cmd`。
|
||||
- 使用包内 Python 3.8.10 安装程序;安装到包内 `.python38`,不把它加入系统 PATH,也不替换其他 Python 版本。
|
||||
- 从本包 `wheels/x64` 或 `wheels/x86` 离线安装到 `.buildenv`,不连接 pip 源、不临时升级依赖。
|
||||
- 自动检查密码哈希、中文 PDF、制单审批、部分/全部付款交货、Excel 台账和 HTTP 服务。自检仅使用临时数据库,通知不开启。
|
||||
- 看到 `SETUP COMPLETE` 后关闭窗口。失败时保留报错内容。
|
||||
2. 双击 `02_build_exe.cmd`。
|
||||
- 再次核对文件校验值、Python 与依赖版本,然后构建单文件程序。
|
||||
- 打包期间可能数分钟没有新输出;详细日志在 `build/时间-位数/build.log`。
|
||||
- 生成后自动在隔离目录启动一次 EXE,检查登录与初始化入口,并关闭该测试进程。
|
||||
- 看到 `BUILD COMPLETE` 后,在 `dist/时间-位数/` 取出 **`PO_Server_Win7.exe`**。同目录还有 `BUILD_INFO.json` 和使用说明。
|
||||
|
||||
32 位 Windows 生成 32 位 EXE,64 位 Windows 生成 64 位 EXE。每次构建写到新的日期目录,不覆盖已有程序或业务数据。请保持本包原始源码及依赖不变,才能通过校验;需要修改源码时由维护人员重新制作本包。
|
||||
|
||||
## 3. 在服务器上使用
|
||||
|
||||
把生成的 EXE 放入固定服务目录,例如 `D:\PO_Server`,双击运行,按窗口显示的端口访问。首次部署时在此目录的 `instance/setup.token` 查看初始化口令。原系统升级时,先停机并完整备份,再替换程序,保留原 `instance`、`uploads`、`exports`;设置过 `PO_DATA_DIR` 的继续指向原数据目录。
|
||||
|
||||
同局域网的员工电脑和手机继续用浏览器访问服务电脑的内网 IP 与实际端口,例如 `http://192.168.1.100:8080`。建议在员工的较新电脑或手机上操作页面;服务电脑只负责运行 EXE,Win7 自带的 IE 不是本项目已验收浏览器。
|
||||
|
||||
首次正式使用前,至少用两个不同账号完成:创建测试订单 → 提交 → 审批盖章 → 下载中文 PDF → 登记部分和全部付款/交货 → 重启程序确认数据仍在。企业微信另按手册第 10-12 页配置并在真实公司 Wi-Fi 下验证。测试完成前,不删除旧程序和原数据备份。
|
||||
|
||||
## 4. 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| Python 安装提示需要 SP1、更新或重启 | 先检查 Windows 7 SP1、DLL 加载更新及 UCRT。完成更新并重启,再运行第一个脚本 |
|
||||
| 缺少 api-ms-win-crt、VCRUNTIME140、找不到程序入口点 | 检查系统运行库和更新;若自检提示具体 DLL,保留完整名称及错误信息。不要从陌生网站下载单个 DLL 覆盖系统目录 |
|
||||
| 文件校验失败 | 重新完整解压本包;确认杀毒软件没有隔离文件、源码和 wheels 未被修改 |
|
||||
| 路径过长、权限不足 | 重新解压到较短、可写的本地路径。不要移动已经建立的 `.buildenv`,在新目录重跑第一个脚本 |
|
||||
| 自检密码或 PDF 失败 | 保留完整错误及依赖信息,暂不部署。包内检查包括新版账号 scrypt 哈希及本机中文字体加载 |
|
||||
| 打包失败 | 查看新建的 `build/时间-位数/build.log`;不要改用普通 `requirements-build.txt`,它用于较新的 Windows/Python |
|
||||
| EXE 自检失败 | 不替换正式服务;检查错误内容、系统补丁和杀毒隔离记录 |
|
||||
| 本机可用,手机打不开 | 按手册核对实际 IP、端口、公司 Wi-Fi 和防火墙。通知配置中的地址也要同步 |
|
||||
|
||||
## 5. 兼容依赖与维护说明
|
||||
|
||||
本包使用主线 v4 业务源码,并包含上述 PDF 排版更新。兼容依赖完全固定,使用 Python 3.8.10、PyInstaller 5.13.2、Flask 3.0.3、Werkzeug 3.0.6、SQLAlchemy 2.0.44、ReportLab 4.2.5、Pillow 10.4.0 等。完整列表在 `win7/requirements-win7.txt`。
|
||||
|
||||
Waitress 使用本项目的 **3.0.2+win7.1** 兼容 wheel。HTTP 服务及安全修复代码与官方 3.0.2 保持逐字节一致,仅为命令行入口的名称解析增加 Python 3.8 回退,并修改版本元数据、重算 wheel 校验记录;原许可证保留。这样保留 3.0.1/3.0.2 的修复,没有降到旧的 3.0.0。上游官方支持范围仍为 Python 3.9+,这是本项目单独测试维护的兼容组合,不代表上游承诺 Win7 支持。原始 wheel 和重打包脚本随包提供。
|
||||
|
||||
`PACKAGE_INFO.json` 记录源代码提交与验证范围;`SHA256SUMS.json` 记录本包文件校验值。构建输出的 `BUILD_INFO.json` 记录实际操作系统、Python、位数和 EXE SHA-256。最终以你的服务器上的自检和现场流程验收为准。
|
||||
|
||||
## 官方参考
|
||||
|
||||
- Python 3.8.10 及官方安装程序:https://www.python.org/downloads/release/python-3810/
|
||||
- Python 3.8 Windows 系统说明:https://docs.python.org/3.8/using/windows.html
|
||||
- PyInstaller 5.13.2 的 Windows 构建与运行库说明:https://pyinstaller.org/en/v5.13.2/usage.html#windows
|
||||
- Waitress 3.0.2 变更记录:https://docs.pylonsproject.org/projects/waitress/en/stable/
|
||||
|
||||
现有 PDF 手册描述 v4 常规 Windows 64 位版本的操作流程。**Win7 安装及打包环境以本文件为准**,无需按通用手册安装 Python 3.11 或 PyInstaller 6。
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Offline setup and EXE build, intended to run on the Windows 7 server."""
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ARCH = 'x64' if struct.calcsize('P') == 8 else 'x86'
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
print('>', subprocess.list2cmdline([str(x) for x in args]), flush=True)
|
||||
subprocess.run([str(x) for x in args], check=True, **kwargs)
|
||||
|
||||
|
||||
def verify():
|
||||
manifest = json.loads((ROOT/'SHA256SUMS.json').read_text(encoding='utf-8'))
|
||||
for name, digest in manifest.items():
|
||||
path = (ROOT/name).resolve()
|
||||
if ROOT.resolve() not in path.parents:
|
||||
raise RuntimeError('Invalid manifest path')
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
||||
raise RuntimeError('File checksum mismatch: ' + name)
|
||||
print('Package checksums verified.', flush=True)
|
||||
|
||||
|
||||
def check_python():
|
||||
if os.name != 'nt' or sys.version_info[:3] != (3, 8, 10):
|
||||
raise RuntimeError('Use the bundled official Windows Python 3.8.10 installer first.')
|
||||
version = sys.getwindowsversion()
|
||||
if (version.major, version.minor) < (6, 1) or ((version.major, version.minor) == (6, 1) and version.service_pack_major < 1):
|
||||
raise RuntimeError('Windows 7 SP1 or later is required.')
|
||||
print('Python:', sys.version.split()[0], ARCH, '| OS:', platform.platform(), flush=True)
|
||||
if (version.major, version.minor) != (6, 1):
|
||||
print('NOTE: This is not Windows 7. The resulting EXE must still be built/tested on Windows 7.', flush=True)
|
||||
|
||||
|
||||
def setup():
|
||||
check_python()
|
||||
verify()
|
||||
env_dir = ROOT/'.buildenv'
|
||||
python = env_dir/'Scripts/python.exe'
|
||||
if not python.exists():
|
||||
run([sys.executable, '-m', 'venv', env_dir])
|
||||
wheels = ROOT/'wheels'/ARCH
|
||||
options = ['--no-index', '--find-links', wheels, '--disable-pip-version-check', '--no-cache-dir']
|
||||
run([python, '-m', 'pip', 'install'] + options + ['pip==25.0.1', 'setuptools==68.2.2', 'wheel==0.45.1'])
|
||||
run([python, '-m', 'pip', 'install'] + options + ['-r', ROOT/'win7/requirements-win7.txt'])
|
||||
run([python, '-m', 'pip', 'check'])
|
||||
run([python, ROOT/'win7/selftest.py', ROOT/'po_app'])
|
||||
print('SETUP COMPLETE. Run 02_build_exe.cmd next.', flush=True)
|
||||
|
||||
|
||||
def build():
|
||||
check_python()
|
||||
verify()
|
||||
# Running from the dedicated environment keeps modern packages out of the build.
|
||||
expected = (ROOT/'.buildenv/Scripts/python.exe').resolve()
|
||||
if Path(sys.executable).resolve() != expected:
|
||||
raise RuntimeError('Run 02_build_exe.cmd after 01_install_env.cmd.')
|
||||
for line in (ROOT/'win7/requirements-win7.txt').read_text().splitlines():
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
package, pinned = line.split('==')
|
||||
if importlib.metadata.version(package) != pinned:
|
||||
raise RuntimeError('Dependency changed; run 01_install_env.cmd again: ' + package)
|
||||
run([sys.executable, ROOT/'win7/selftest.py', ROOT/'po_app'])
|
||||
build_id = datetime.now().strftime('%Y%m%d-%H%M%S-%f') + '-' + ARCH
|
||||
work = ROOT/'build'/build_id
|
||||
output = ROOT/'dist'/build_id
|
||||
work.mkdir(parents=True)
|
||||
output.mkdir(parents=True)
|
||||
env = os.environ.copy()
|
||||
env['PYINSTALLER_CONFIG_DIR'] = str(work/'cache')
|
||||
command = [sys.executable, '-m', 'PyInstaller', '--noconfirm', '--onefile', '--console',
|
||||
'--noupx', '--name', 'PO_Server_Win7', '--distpath', output,
|
||||
'--workpath', work/'temp', '--specpath', work,
|
||||
'--paths', ROOT/'po_app',
|
||||
'--add-data', str(ROOT/'po_app/templates') + ';templates',
|
||||
'--add-data', str(ROOT/'po_app/static') + ';static',
|
||||
'--hidden-import', 'sqlalchemy.dialects.sqlite',
|
||||
'--hidden-import', 'openpyxl', '--hidden-import', 'xlrd',
|
||||
'--collect-all', 'reportlab', ROOT/'po_app/app.py']
|
||||
log_path = work/'build.log'
|
||||
print('Building EXE. Build log:', log_path, flush=True)
|
||||
with log_path.open('wb') as log:
|
||||
subprocess.run([str(x) for x in command], cwd=ROOT, env=env,
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
exe = output/'PO_Server_Win7.exe'
|
||||
run([sys.executable, ROOT/'win7/probe_exe.py', exe])
|
||||
info = {'built_on': platform.platform(), 'python': sys.version, 'architecture': ARCH,
|
||||
'source_commit': json.loads((ROOT/'PACKAGE_INFO.json').read_text(encoding='utf-8'))['source_commit'],
|
||||
'sha256': hashlib.sha256(exe.read_bytes()).hexdigest(),
|
||||
'source_selftest': 'passed', 'exe_startup_probe': 'passed',
|
||||
'built_on_windows7': sys.getwindowsversion()[:2] == (6, 1)}
|
||||
(output/'BUILD_INFO.json').write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
shutil.copy2(ROOT/'README_WIN7.txt', output/'README_WIN7.txt')
|
||||
for pdf in (ROOT/'manual').glob('*.pdf'):
|
||||
shutil.copy2(pdf, output/pdf.name)
|
||||
print('BUILD COMPLETE:', exe, flush=True)
|
||||
print('Copy EXE to a fixed service folder. Keep the original instance/uploads/exports when upgrading.', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=['setup', 'build', 'verify'])
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
{'setup': setup, 'build': build, 'verify': verify}[args.command]()
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print('FAILED. Do not replace the running server. Check the message and build log above.', flush=True)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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']:
|
||||
copy(HERE/name, 'win7/' + name)
|
||||
for name in ['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'),
|
||||
'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)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Repackage official Waitress 3.0.2 metadata for the tested Python 3.8 build.
|
||||
|
||||
The HTTP server and security fixes are kept byte-for-byte. Only the CLI name
|
||||
resolver gets a Python 3.8 fallback. The local version identifies the adaptation.
|
||||
"""
|
||||
import base64
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
UPSTREAM_SHA256 = 'c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e'
|
||||
VERSION = '3.0.2+win7.1'
|
||||
|
||||
|
||||
def repackage(source, destination):
|
||||
source, destination = Path(source), Path(destination)
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != UPSTREAM_SHA256:
|
||||
raise ValueError('Unexpected upstream Waitress wheel checksum')
|
||||
old_info, new_info = 'waitress-3.0.2.dist-info', 'waitress-%s.dist-info' % VERSION
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / ('waitress-%s-py3-none-any.whl' % VERSION)
|
||||
records = []
|
||||
with zipfile.ZipFile(source) as src, zipfile.ZipFile(target, 'w', zipfile.ZIP_DEFLATED) as out:
|
||||
for name in src.namelist():
|
||||
if name.endswith('/RECORD'):
|
||||
continue
|
||||
content = src.read(name)
|
||||
new_name = name.replace(old_info, new_info)
|
||||
if name == 'waitress/runner.py':
|
||||
assert b'import pkgutil\n' in content and b'pkgutil.resolve_name(args[0])' in content
|
||||
content = content.replace(b'import pkgutil\n',
|
||||
b'try:\n from pkgutil import resolve_name\nexcept ImportError:\n from pkgutil_resolve_name import resolve_name\n')
|
||||
content = content.replace(b'pkgutil.resolve_name(args[0])', b'resolve_name(args[0])')
|
||||
if name == old_info + '/METADATA':
|
||||
content = content.replace(b'Version: 3.0.2\n', ('Version: %s\n' % VERSION).encode())
|
||||
content = content.replace(b'Requires-Python: >=3.9.0\n',
|
||||
b'Requires-Python: >=3.8.0\nRequires-Dist: pkgutil-resolve-name==1.3.10; python_version < "3.9"\n')
|
||||
assert b'Requires-Python: >=3.8.0\n' in content
|
||||
out.writestr(new_name, content)
|
||||
digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b'=').decode()
|
||||
records.append((new_name, 'sha256=' + digest, str(len(content))))
|
||||
records.append((new_info + '/RECORD', '', ''))
|
||||
record = io.StringIO(newline='')
|
||||
csv.writer(record, lineterminator='\n').writerows(records)
|
||||
out.writestr(new_info + '/RECORD', record.getvalue())
|
||||
with zipfile.ZipFile(source) as src, zipfile.ZipFile(target) as dest:
|
||||
assert all(src.read(name) == dest.read(name) for name in src.namelist()
|
||||
if name.startswith('waitress/') and name != 'waitress/runner.py')
|
||||
return target
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('source', type=Path)
|
||||
parser.add_argument('destination', type=Path)
|
||||
args = parser.parse_args()
|
||||
print(repackage(args.source, args.destination))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Start the built EXE in isolation, verify setup/login, then stop its own tree."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
def probe(executable):
|
||||
with tempfile.TemporaryDirectory(prefix='po-win7-exe-') as temporary:
|
||||
directory = Path(temporary)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.upper().startswith(('PO_', 'PYTHON', 'VIRTUAL_ENV'))}
|
||||
env.update(PO_DATA_DIR=str(directory), PO_HOST='127.0.0.1', PO_OPEN_BROWSER='0')
|
||||
http = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with (directory/'server.log').open('wb') as log:
|
||||
process = subprocess.Popen([str(Path(executable).resolve()), str(port)], env=env,
|
||||
cwd=directory, stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
try:
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError('EXE exited: ' + (directory/'server.log').read_text(errors='replace'))
|
||||
try:
|
||||
with http.open('http://127.0.0.1:%s/login' % port, timeout=1) as response:
|
||||
if response.status == 200:
|
||||
assert (directory/'instance/setup.token').is_file()
|
||||
print('EXE STARTUP PASSED:', executable)
|
||||
return
|
||||
except (OSError, TimeoutError):
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError('EXE did not start within 90 seconds')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
process.wait(timeout=20)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
probe(sys.argv[1])
|
||||
@@ -0,0 +1,32 @@
|
||||
# Windows 7 compatibility environment: CPython 3.8.10 only.
|
||||
# The local Waitress wheel adapts only the CLI resolver; see README.
|
||||
Flask==3.0.3
|
||||
Werkzeug==3.0.6
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==2.1.5
|
||||
click==8.1.8
|
||||
itsdangerous==2.2.0
|
||||
blinker==1.8.2
|
||||
colorama==0.4.6
|
||||
SQLAlchemy==2.0.44
|
||||
greenlet==3.1.1
|
||||
typing_extensions==4.13.2
|
||||
reportlab==4.2.5
|
||||
pillow==10.4.0
|
||||
chardet==5.2.0
|
||||
openpyxl==3.1.5
|
||||
et-xmlfile==2.0.0
|
||||
xlrd==2.0.2
|
||||
waitress==3.0.2+win7.1
|
||||
pkgutil-resolve-name==1.3.10
|
||||
importlib-metadata==8.5.0
|
||||
zipp==3.20.2
|
||||
PyInstaller==5.13.2
|
||||
pyinstaller-hooks-contrib==2023.8
|
||||
pefile==2023.2.7
|
||||
pywin32-ctypes==0.2.3
|
||||
altgraph==0.17.4
|
||||
setuptools==68.2.2
|
||||
wheel==0.45.1
|
||||
pip==25.0.1
|
||||
packaging==24.2
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Exercise the real application with isolated data; never send notifications."""
|
||||
import argparse
|
||||
from datetime import date
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
|
||||
def run(source, artifacts=None):
|
||||
import openpyxl
|
||||
from PIL import Image
|
||||
from waitress import create_server
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
password = 'Win7-Test-' + secrets.token_hex(10)
|
||||
hashed = generate_password_hash(password)
|
||||
assert check_password_hash(hashed, password), 'Password hashing failed'
|
||||
# Also test the scrypt parameters generated by newer Windows deployments.
|
||||
assert check_password_hash(generate_password_hash(password, method='scrypt:32768:8:1'), password)
|
||||
Image.new('RGB', (3, 3)).save(io.BytesIO(), format='PNG')
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='po-win7-check-') as temporary:
|
||||
os.environ['PO_DATA_DIR'] = temporary
|
||||
os.environ.pop('PO_SECRET_KEY', None)
|
||||
os.environ.pop('PO_HTTPS', None)
|
||||
sys.path.insert(0, str(source.resolve()))
|
||||
import app as module
|
||||
from models import User
|
||||
module.app.config['TESTING'] = True
|
||||
assert (Path(temporary)/'instance/setup.token').is_file()
|
||||
with module.Session() as session:
|
||||
for name, role in [('maker', 'maker'), ('admin', 'admin')]:
|
||||
session.add(User(username=name, name=name, role=role, active=True,
|
||||
must_change_password=False, session_version=1, password_hash=hashed))
|
||||
session.commit()
|
||||
|
||||
def post(client, path, data):
|
||||
client.get('/login')
|
||||
with client.session_transaction() as session:
|
||||
token = session['csrf_token']
|
||||
return client.post(path, data=dict(data, csrf_token=token))
|
||||
|
||||
clients = {}
|
||||
for name in ('maker', 'admin'):
|
||||
client = module.app.test_client()
|
||||
assert post(client, '/login', {'username': name, 'password': password}).status_code == 302
|
||||
clients[name] = client
|
||||
|
||||
def version(oid):
|
||||
with module.Session() as session:
|
||||
return session.get(module.Order, oid).row_version
|
||||
|
||||
def action(actor, oid, endpoint, **fields):
|
||||
fields['row_version'] = str(version(oid))
|
||||
return post(clients[actor], '/orders/%d/%s' % (oid, endpoint), fields)
|
||||
|
||||
try:
|
||||
for kind in ('purchase', 'outsourcing'):
|
||||
response = post(clients['maker'], '/orders/new',
|
||||
{'doc_type': kind, 'request_key': secrets.token_hex(16)})
|
||||
assert response.status_code == 302
|
||||
oid = int(re.search(r'/orders/(\d+)', response.location).group(1))
|
||||
response = action('maker', oid, 'edit', supplier_name='Win7 test supplier',
|
||||
our_company='苏州博特维智能科技有限公司', payment_terms='月结',
|
||||
delivery_place='苏州', order_date=date.today().isoformat(),
|
||||
item_code='TEST-001', item_spec='测试物料', item_unit='件',
|
||||
item_qty='2', item_price='12.3456', item_date=date.today().isoformat(),
|
||||
item_remark='', intent='submit')
|
||||
assert response.status_code == 302, response.get_data(as_text=True)
|
||||
assert action('maker', oid, 'approve', stamped='1').status_code == 403
|
||||
assert action('admin', oid, 'approve', stamped='1').status_code == 302
|
||||
pdf = clients['maker'].get('/orders/%d/pdf' % oid).data
|
||||
assert pdf.startswith(b'%PDF-') and b'%%EOF' in pdf
|
||||
if artifacts:
|
||||
artifacts.mkdir(parents=True, exist_ok=True)
|
||||
(artifacts/(kind + '.pdf')).write_bytes(pdf)
|
||||
for payment, delivery in [('partial', 'partial'), ('paid', 'delivered')]:
|
||||
assert action('maker', oid, 'follow-up', needs_prepayment='1',
|
||||
invoice_status='received', delivery_status=delivery,
|
||||
fulfillment_note='离线环境自检', reason='').status_code == 302
|
||||
assert action('admin', oid, 'payment', payment_status=payment,
|
||||
payment_date=date.today().isoformat(), payment_note='', reason='').status_code == 302
|
||||
assert clients['maker'].get('/orders/%d/pdf' % oid).data == pdf
|
||||
print('PASS:', kind, 'login, submit, approval, PDF, partial/full follow-up, archive')
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(clients['admin'].get('/export/ledger').data))
|
||||
assert workbook.active.max_row == 3
|
||||
workbook.close()
|
||||
assert clients['admin'].get('/orders?tracking=completed').status_code == 200
|
||||
assert clients['admin'].get('/settings/notifications').status_code == 200
|
||||
# Test a real HTTP socket using the exact Waitress compatibility wheel.
|
||||
server = create_server(module.app, host='127.0.0.1', port=0, threads=2)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
http = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with http.open('http://127.0.0.1:%s/login' % server.effective_port, timeout=10) as response:
|
||||
assert response.status == 200
|
||||
response.read()
|
||||
finally:
|
||||
server.task_dispatcher.shutdown()
|
||||
server.close()
|
||||
thread.join(timeout=10)
|
||||
print('PASS: Excel, dashboard, notification settings, Waitress HTTP')
|
||||
finally:
|
||||
module.engine.dispose()
|
||||
print('SELFTEST PASSED. Only temporary data was used. No group messages sent.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('source', type=Path)
|
||||
parser.add_argument('--artifacts', type=Path)
|
||||
args = parser.parse_args()
|
||||
run(args.source, args.artifacts)
|
||||
Reference in New Issue
Block a user