Files
EDMarketConnector/build.py
T

460 lines
14 KiB
Python

"""
build.py - Build the program EXE.
Copyright (c) EDCD, All Rights Reserved
Licensed under the GNU General Public License v2 or later.
See LICENSE file.
"""
from collections.abc import Sequence
import shutil
import sys
import pathlib
from string import Template
import py2exe
import zipfile
from datetime import date
from config import (
appcmdname,
appname,
appversion,
copyright,
git_shorthash_from_head,
_static_appversion,
update_interval,
)
from update import check_for_fdev_updates, check_for_datafile_updates
AUDIT_DEPS = False
if "--audit_deps" in sys.argv:
AUDIT_DEPS = True
sys.argv.remove("--audit_deps")
DEPENDENCY_DOC_PATH = pathlib.Path("docs") / "Bundled_Python_Dependencies.md"
def iss_build(template_path: str, output_file: str) -> None:
"""Build the .iss file needed for building the installer EXE."""
sub_vals = {
"appver": _static_appversion,
"update_time": str(update_interval),
}
with open(template_path, encoding="UTF8") as template_file:
src = Template(template_file.read())
newfile = src.substitute(sub_vals)
with open(output_file, "w", encoding="UTF8") as new_file:
new_file.write(newfile)
def system_check(dist_dir: pathlib.Path) -> str:
"""Check if the system is able to build."""
if sys.version_info < (3, 13):
sys.exit(f"EDMC cannot build on Python {sys.version}. Minimum version: 3.13")
if sys.platform != "win32":
sys.exit(f"Unsupported platform {sys.platform}")
git_shorthash = git_shorthash_from_head()
if git_shorthash is None:
sys.exit("Invalid Git Hash")
gitversion_file = ".gitversion"
with open(gitversion_file, "w+", encoding="utf-8") as gvf:
gvf.write(git_shorthash)
print(f"Git short hash: {git_shorthash}")
if dist_dir and pathlib.Path.is_dir(dist_dir):
shutil.rmtree(dist_dir)
return gitversion_file
def generate_data_files(
app_name: str, gitversion_file: str, plugins: list[str]
) -> Sequence[tuple[object, object]]:
"""Create the required datafiles to build."""
l10n_dir = pathlib.Path("L10n")
fdevids_dir = pathlib.Path("FDevIDs")
license_dir = pathlib.Path("docs/Licenses")
data_files = [
(
"",
[
gitversion_file,
"WinSparkle.dll",
"WinSparkle.pdb",
"EUROCAPS.TTF",
"ChangeLog.md",
"snd_good.wav",
"snd_bad.wav",
f"{app_name}.ico",
f"resources/{appcmdname}.ico",
"EDMarketConnector - TRACE.bat",
"EDMarketConnector - localserver-auth.bat",
"EDMarketConnector - reset-ui.bat",
],
),
(
str(l10n_dir),
[str(x) for x in l10n_dir.iterdir() if x.suffix == ".strings"],
),
(
str(fdevids_dir),
[
str(fdevids_dir / "commodity.csv"),
str(fdevids_dir / "rare_commodity.csv"),
],
),
("plugins", plugins),
]
# Add all files recursively from license directories
for root, dirs, files in license_dir.walk():
file_list = [str(root / f) for f in files]
dest_dir = str(license_dir / root.relative_to(license_dir))
data_files.append((dest_dir, file_list))
return data_files
def _scan_dist_for_modules( # noqa: C901, CCR001
dist_dir: pathlib.Path,
) -> dict[str, set[str]]:
"""Return mapping of modules from source path strings."""
modules: dict[str, set[str]] = {}
def add(name: str, origin: str):
modules.setdefault(name, set()).add(origin)
for root, dirs, files in dist_dir.walk():
for name in files:
path = root / name # root is a Path object, name is a string
# Loose files
if path.suffix in (".py", ".pyc"):
try:
rel = path.relative_to(dist_dir)
except ValueError:
continue
parts = rel.parts
if not parts:
continue
top = parts[0]
if top.endswith((".py", ".pyc")):
top = pathlib.Path(top).stem
add(top, str(rel))
# Zips
elif path.suffix == ".zip":
try:
with zipfile.ZipFile(path) as z:
for info in z.infolist():
if info.filename.endswith((".py", ".pyc")):
parts = tuple(info.filename.split("/"))
if not parts:
continue
top = parts[0]
if top.endswith((".py", ".pyc")):
top = pathlib.Path(top).stem
add(top, f"{path.name}:{info.filename}")
except zipfile.BadZipFile:
pass
modules.pop("__pycache__", None)
modules.pop("", None)
return modules
def _is_project_module(module_name: str, project_root: pathlib.Path) -> bool:
"""Determine if this module name corresponds to EDMC source code."""
if (project_root / f"{module_name}.py").exists():
return True
if (project_root / module_name).is_dir():
return True
return False
def _discover_project_modules(project_root: pathlib.Path) -> set[str]:
"""Discover top-level Python modules/packages in the project source tree."""
mods: set[str] = set()
for entry in project_root.iterdir():
if entry.name.startswith("."):
continue
if entry.is_file() and entry.suffix == ".py":
mods.add(entry.stem)
elif entry.is_dir():
if (entry / "__init__.py").exists():
mods.add(entry.name)
return mods
def _classify_modules(
modules: set[str], project_modules: set[str]
) -> tuple[list[str], list[str], list[str]]:
"""Split modules into stdlib, third-party, and custom."""
stdlib = set(sys.stdlib_module_names)
stdlib_mods: list[str] = []
third_party_mods: list[str] = []
custom_mods: list[str] = []
for name in sorted(modules):
if name in stdlib:
stdlib_mods.append(name)
elif name in project_modules:
custom_mods.append(name)
else:
third_party_mods.append(name)
return stdlib_mods, third_party_mods, custom_mods
def write_dependency_markdown(dist_dir: pathlib.Path) -> None: # noqa: CCR001
"""Generate docs/Bundled_Python_Dependencies.md.
Lists:
- Third-party modules bundled
- Standard library modules that are actually bundled
Excludes:
- All EDMC project code
"""
module_map = _scan_dist_for_modules(dist_dir)
project_root = pathlib.Path(__file__).parent
stdlib = set(sys.stdlib_module_names)
bundled_stdlib: list[str] = []
bundled_third_party: list[str] = []
for name in sorted(module_map):
# Exclude EDMC code by filesystem presence
if _is_project_module(name, project_root):
continue
# Exclude entry point and Python internal test modules
if name in ("__main__",) or name.startswith("_test"):
continue
if name in stdlib:
bundled_stdlib.append(name)
else:
bundled_third_party.append(name)
DEPENDENCY_DOC_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DEPENDENCY_DOC_PATH, "w", encoding="utf-8") as f:
f.write("# Bundled Python Dependencies\n\n")
f.write("This file is **auto-generated by the build system**.\n\n")
f.write(
f"This file was last updated for {_static_appversion} on {date.today()}.\n\n"
)
f.write(
"This document lists Python modules that are bundled into the EDMC"
" executable by the Windows build process.\n"
)
f.write(
"It is intended for **transparency, auditing, debugging, and license compliance**.\n\n"
)
f.write("## Important Notes\n\n")
f.write("- **This is NOT a public API guarantee.**\n")
f.write(
"- The presence of a module in this list does **NOT** mean it is "
"supported for plugins or external use.\n"
)
f.write(
"- Bundled modules may be **added, removed, upgraded, downgraded, or "
"replaced at any time** without notice.\n"
)
f.write(
"- This list reflects **one specific build configuration** and may differ "
"between releases, platforms, or build options.\n\n"
)
f.write("## For Plugin Developers\n\n")
f.write("- Always vendor or declare your own dependencies.\n")
f.write("- Only the documented EDMC plugin API is considered stable.\n")
f.write("- Use try/except imports for optional features.\n")
f.write("- Do not rely on pywin32 internals for cross-platform plugins.\n")
f.write(
"- While it is likely that these dependencies will be available, this is not part of the public API.\n\n"
)
f.write("## For Developers and Packagers\n\n")
f.write("- This reflects what py2exe determined was required for this build.\n")
f.write(
"- Changes in Python, py2exe, or import structure may significantly change this list.\n"
)
f.write(
"- Only modules actually bundled by py2exe are listed; this is "
"**not all of the Python standard library**.\n"
)
f.write(
"- This list is **not sorted by importance, stability, or API relevance**.\n\n"
)
f.write(
"EDMC's own source code and first-party modules are intentionally excluded from this document.\n\n"
)
f.write("## Bundled Standard Library Modules\n\n")
if not bundled_stdlib:
f.write("*(None)*\n")
else:
for name in bundled_stdlib:
f.write(f"- `{name}`\n")
f.write("\n## Bundled Third-Party Modules\n\n")
if not bundled_third_party:
f.write("*(None)*\n")
else:
for name in bundled_third_party:
note = ""
if name.startswith("win32") or name in (
"pythoncom",
"pywintypes",
"pywin32_system32",
):
note = " (Windows-only, pywin32)"
f.write(f"- `{name}`{note}\n")
f.write("\n---\n")
f.write(f"\nTotal bundled stdlib modules: {len(bundled_stdlib)}\n")
f.write(f"Total bundled third-party modules: {len(bundled_third_party)}\n")
print(f"Wrote dependency manifest to {DEPENDENCY_DOC_PATH}")
def build(audit_deps: bool = False) -> None:
"""Build EDMarketConnector using Py2Exe."""
dist_dir: pathlib.Path = pathlib.Path("dist.win32")
gitversion_filename: str = system_check(dist_dir)
# Constants
plugins: list[str] = [
"plugins/coriolis.py",
"plugins/eddn.py",
"plugins/edsm.py",
"plugins/edsy.py",
"plugins/inara.py",
"plugins/spansh_core.py",
"plugins/edastro_core.py",
"plugins/common_coreutils.py",
]
options: dict = {
"py2exe": {
"dist_dir": dist_dir,
"optimize": 2,
"packages": ["asyncio", "multiprocessing", "sqlite3", "plugins"],
"includes": ["dataclasses", "shutil", "timeout_session", "zipfile"],
"excludes": [
"distutils",
"_markerlib",
"optparse",
"simplejson",
"unittest",
"doctest",
"pdb",
"difflib",
],
}
}
data_files = generate_data_files(appname, gitversion_filename, plugins)
version_info: dict = {
"description": "Elite Dangerous Market Connector (EDMC)",
"comments": "Downloads commodity market and other station data from the game"
" Elite Dangerous for use with all popular online and offline trading tools.",
"company_name": "EDCD",
"product_name": appname,
"version": str(appversion().truncate()),
"product_version": str(appversion()),
"copyright": copyright,
"language": "English (United States)",
}
windows_config: dict = {
"dest_base": appname,
"script": "EDMarketConnector.py",
"icon_resources": [(0, f"{appname}.ico")],
"other_resources": [
(
24,
1,
pathlib.Path(f"resources/{appname}.manifest").read_text(
encoding="UTF8"
),
)
],
}
console_config: dict = {
"dest_base": appcmdname,
"script": "EDMC.py",
"icon_resources": [(0, f"resources/{appcmdname}.ico")],
"other_resources": [
(
24,
1,
pathlib.Path(f"resources/{appcmdname}.manifest").read_text(
encoding="UTF8"
),
)
],
}
checker_config: dict = {
"dest_base": "EDMCSystemProfiler",
"script": "EDMCSystemProfiler.py",
"icon_resources": [(0, f"{appname}.ico")],
"other_resources": [
(
24,
1,
pathlib.Path(f"resources/{appname}.manifest").read_text(
encoding="UTF8"
),
)
],
}
try:
py2exe.freeze(
version_info=version_info,
windows=[windows_config, checker_config],
console=[console_config],
data_files=data_files,
options=options,
)
except FileNotFoundError as err:
print(err)
sys.exit(
"Build Failed due to Missing Files! Have you set up your submodules? \n"
"https://github.com/EDCD/EDMarketConnector/wiki/Running-from-source"
"#obtain-a-copy-of-the-application-source"
)
if audit_deps:
write_dependency_markdown(dist_dir)
iss_template_path: str = "./resources/EDMC_Installer_Config_template.txt"
iss_file_path: str = "./EDMC_Installer_Config.iss"
iss_build(iss_template_path, iss_file_path)
if __name__ == "__main__":
check_for_fdev_updates(local=True)
check_for_datafile_updates(local=True)
build(audit_deps=AUDIT_DEPS)