updateForge.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. """
  2. Get the source files necessary for generating Forge versions
  3. """
  4. import copy
  5. import hashlib
  6. import json
  7. import os
  8. import re
  9. import sys
  10. import zipfile
  11. from contextlib import suppress
  12. from datetime import datetime
  13. from pathlib import Path
  14. from pprint import pprint
  15. import requests
  16. from cachecontrol import CacheControl
  17. from cachecontrol.caches import FileCache
  18. from pydantic import ValidationError
  19. from meta.common import upstream_path, ensure_upstream_dir, static_path
  20. from meta.common.forge import JARS_DIR, INSTALLER_INFO_DIR, INSTALLER_MANIFEST_DIR, VERSION_MANIFEST_DIR, \
  21. FILE_MANIFEST_DIR, BAD_VERSIONS, STATIC_LEGACYINFO_FILE
  22. from meta.model.forge import ForgeFile, ForgeEntry, ForgeMCVersionInfo, ForgeLegacyInfoList, DerivedForgeIndex, \
  23. ForgeVersion, ForgeInstallerProfile, ForgeInstallerProfileV2, InstallerInfo, \
  24. ForgeLegacyInfo
  25. from meta.model.mojang import MojangVersion
  26. UPSTREAM_DIR = upstream_path()
  27. STATIC_DIR = static_path()
  28. ensure_upstream_dir(JARS_DIR)
  29. ensure_upstream_dir(INSTALLER_INFO_DIR)
  30. ensure_upstream_dir(INSTALLER_MANIFEST_DIR)
  31. ensure_upstream_dir(VERSION_MANIFEST_DIR)
  32. ensure_upstream_dir(FILE_MANIFEST_DIR)
  33. LEGACYINFO_PATH = os.path.join(STATIC_DIR, STATIC_LEGACYINFO_FILE)
  34. forever_cache = FileCache('caches/http_cache', forever=True)
  35. sess = CacheControl(requests.Session(), forever_cache)
  36. def eprint(*args, **kwargs):
  37. print(*args, file=sys.stderr, **kwargs)
  38. def filehash(filename, hashtype, blocksize=65536):
  39. hashtype = hashtype()
  40. with open(filename, "rb") as f:
  41. for block in iter(lambda: f.read(blocksize), b""):
  42. hashtype.update(block)
  43. return hashtype.hexdigest()
  44. def get_single_forge_files_manifest(longversion):
  45. print(f"Getting Forge manifest for {longversion}")
  46. path_thing = UPSTREAM_DIR + "/forge/files_manifests/%s.json" % longversion
  47. files_manifest_file = Path(path_thing)
  48. from_file = False
  49. if files_manifest_file.is_file():
  50. with open(path_thing, 'r') as f:
  51. files_json = json.load(f)
  52. from_file = True
  53. else:
  54. file_url = 'https://files.minecraftforge.net/net/minecraftforge/forge/%s/meta.json' % longversion
  55. r = sess.get(file_url)
  56. r.raise_for_status()
  57. files_json = r.json()
  58. ret_dict = dict()
  59. for classifier, extensionObj in files_json.get('classifiers').items():
  60. assert type(classifier) == str
  61. assert type(extensionObj) == dict
  62. # assert len(extensionObj.items()) == 1
  63. index = 0
  64. count = 0
  65. while index < len(extensionObj.items()):
  66. mutable_copy = copy.deepcopy(extensionObj)
  67. extension, hashtype = mutable_copy.popitem()
  68. if not type(classifier) == str:
  69. pprint(classifier)
  70. pprint(extensionObj)
  71. if not type(hashtype) == str:
  72. pprint(classifier)
  73. pprint(extensionObj)
  74. print('%s: Skipping missing hash for extension %s:' % (longversion, extension))
  75. index += 1
  76. continue
  77. assert type(classifier) == str
  78. processed_hash = re.sub(r"\W", "", hashtype)
  79. if not len(processed_hash) == 32:
  80. print('%s: Skipping invalid hash for extension %s:' % (longversion, extension))
  81. pprint(extensionObj)
  82. index += 1
  83. continue
  84. file_obj = ForgeFile(
  85. classifier=classifier,
  86. hash=processed_hash,
  87. extension=extension
  88. )
  89. if count == 0:
  90. ret_dict[classifier] = file_obj
  91. index += 1
  92. count += 1
  93. else:
  94. print('%s: Multiple objects detected for classifier %s:' % (longversion, classifier))
  95. pprint(extensionObj)
  96. assert False
  97. if not from_file:
  98. with open(path_thing, 'w', encoding='utf-8') as f:
  99. json.dump(files_json, f, sort_keys=True, indent=4)
  100. return ret_dict
  101. def main():
  102. # get the remote version list fragments
  103. r = sess.get('https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json')
  104. r.raise_for_status()
  105. main_json = r.json()
  106. assert type(main_json) == dict
  107. r = sess.get('https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json')
  108. r.raise_for_status()
  109. promotions_json = r.json()
  110. assert type(promotions_json) == dict
  111. promoted_key_expression = re.compile(
  112. "(?P<mc>[^-]+)-(?P<promotion>(latest)|(recommended))(-(?P<branch>[a-zA-Z0-9\\.]+))?")
  113. recommended_set = set()
  114. new_index = DerivedForgeIndex()
  115. # FIXME: does not fully validate that the file has not changed format
  116. # NOTE: For some insane reason, the format of the versions here is special. It having a branch at the end means it
  117. # affects that particular branch.
  118. # We don't care about Forge having branches.
  119. # Therefore we only use the short version part for later identification and filter out the branch-specific
  120. # promotions (among other errors).
  121. print("Processing promotions:")
  122. for promoKey, shortversion in promotions_json.get('promos').items():
  123. match = promoted_key_expression.match(promoKey)
  124. if not match:
  125. print('Skipping promotion %s, the key did not parse:' % promoKey)
  126. pprint(promoKey)
  127. assert match
  128. if not match.group('mc'):
  129. print('Skipping promotion %s, because it has no Minecraft version.' % promoKey)
  130. continue
  131. if match.group('branch'):
  132. print('Skipping promotion %s, because it on a branch only.' % promoKey)
  133. continue
  134. elif match.group('promotion') == 'recommended':
  135. recommended_set.add(shortversion)
  136. print('%s added to recommended set' % shortversion)
  137. elif match.group('promotion') == 'latest':
  138. pass
  139. else:
  140. assert False
  141. version_expression = re.compile(
  142. "^(?P<mc>[0-9a-zA-Z_\\.]+)-(?P<ver>[0-9\\.]+\\.(?P<build>[0-9]+))(-(?P<branch>[a-zA-Z0-9\\.]+))?$")
  143. print("")
  144. print("Processing versions:")
  145. for mc_version, value in main_json.items():
  146. assert type(mc_version) == str
  147. assert type(value) == list
  148. for long_version in value:
  149. assert type(long_version) == str
  150. match = version_expression.match(long_version)
  151. if not match:
  152. pprint(long_version)
  153. assert match
  154. assert match.group('mc') == mc_version
  155. files = get_single_forge_files_manifest(long_version)
  156. build = int(match.group('build'))
  157. version = match.group('ver')
  158. branch = match.group('branch')
  159. is_recommended = (version in recommended_set)
  160. entry = ForgeEntry(
  161. long_version=long_version,
  162. mc_version=mc_version,
  163. version=version,
  164. build=build,
  165. branch=branch,
  166. # NOTE: we add this later after the fact. The forge promotions file lies about these.
  167. latest=False,
  168. recommended=is_recommended,
  169. files=files
  170. )
  171. new_index.versions[long_version] = entry
  172. if not new_index.by_mc_version:
  173. new_index.by_mc_version = dict()
  174. if mc_version not in new_index.by_mc_version:
  175. new_index.by_mc_version.setdefault(mc_version, ForgeMCVersionInfo())
  176. new_index.by_mc_version[mc_version].versions.append(long_version)
  177. # NOTE: we add this later after the fact. The forge promotions file lies about these.
  178. # if entry.latest:
  179. # new_index.by_mc_version[mc_version].latest = long_version
  180. if entry.recommended:
  181. new_index.by_mc_version[mc_version].recommended = long_version
  182. print("")
  183. print("Post processing promotions and adding missing 'latest':")
  184. for mc_version, info in new_index.by_mc_version.items():
  185. latest_version = info.versions[-1]
  186. info.latest = latest_version
  187. new_index.versions[latest_version].latest = True
  188. print("Added %s as latest for %s" % (latest_version, mc_version))
  189. print("")
  190. print("Dumping index files...")
  191. with open(UPSTREAM_DIR + "/forge/maven-metadata.json", 'w', encoding='utf-8') as f:
  192. json.dump(main_json, f, sort_keys=True, indent=4)
  193. with open(UPSTREAM_DIR + "/forge/promotions_slim.json", 'w', encoding='utf-8') as f:
  194. json.dump(promotions_json, f, sort_keys=True, indent=4)
  195. new_index.write(UPSTREAM_DIR + "/forge/derived_index.json")
  196. legacy_info_list = ForgeLegacyInfoList()
  197. print("Grabbing installers and dumping installer profiles...")
  198. # get the installer jars - if needed - and get the installer profiles out of them
  199. for key, entry in new_index.versions.items():
  200. eprint("Updating Forge %s" % key)
  201. if entry.mc_version is None:
  202. eprint("Skipping %d with invalid MC version" % entry.build)
  203. continue
  204. version = ForgeVersion(entry)
  205. if version.url() is None:
  206. eprint("Skipping %d with no valid files" % version.build)
  207. continue
  208. if version.long_version in BAD_VERSIONS:
  209. eprint(f"Skipping bad version {version.long_version}")
  210. continue
  211. jar_path = os.path.join(UPSTREAM_DIR, JARS_DIR, version.filename())
  212. if version.uses_installer():
  213. installer_info_path = UPSTREAM_DIR + "/forge/installer_info/%s.json" % version.long_version
  214. profile_path = UPSTREAM_DIR + "/forge/installer_manifests/%s.json" % version.long_version
  215. version_file_path = UPSTREAM_DIR + "/forge/version_manifests/%s.json" % version.long_version
  216. installer_refresh_required = not os.path.isfile(profile_path) or not os.path.isfile(installer_info_path)
  217. if installer_refresh_required:
  218. # grab the installer if it's not there
  219. if not os.path.isfile(jar_path):
  220. eprint("Downloading %s" % version.url())
  221. rfile = sess.get(version.url(), stream=True)
  222. rfile.raise_for_status()
  223. with open(jar_path, 'wb') as f:
  224. for chunk in rfile.iter_content(chunk_size=128):
  225. f.write(chunk)
  226. eprint("Processing %s" % version.url())
  227. # harvestables from the installer
  228. if not os.path.isfile(profile_path):
  229. print(jar_path)
  230. with zipfile.ZipFile(jar_path) as jar:
  231. with suppress(KeyError):
  232. with jar.open('version.json') as profile_zip_entry:
  233. version_data = profile_zip_entry.read()
  234. # Process: does it parse?
  235. MojangVersion.parse_raw(version_data)
  236. with open(version_file_path, 'wb') as versionJsonFile:
  237. versionJsonFile.write(version_data)
  238. versionJsonFile.close()
  239. with jar.open('install_profile.json') as profile_zip_entry:
  240. install_profile_data = profile_zip_entry.read()
  241. # Process: does it parse?
  242. is_parsable = False
  243. exception = None
  244. try:
  245. ForgeInstallerProfile.parse_raw(install_profile_data)
  246. is_parsable = True
  247. except ValidationError as err:
  248. exception = err
  249. try:
  250. ForgeInstallerProfileV2.parse_raw(install_profile_data)
  251. is_parsable = True
  252. except ValidationError as err:
  253. exception = err
  254. if not is_parsable:
  255. if version.is_supported():
  256. raise exception
  257. else:
  258. eprint(
  259. "Version %s is not supported and won't be generated later." % version.long_version)
  260. with open(profile_path, 'wb') as profileFile:
  261. profileFile.write(install_profile_data)
  262. profileFile.close()
  263. # installer info v1
  264. if not os.path.isfile(installer_info_path):
  265. installer_info = InstallerInfo()
  266. installer_info.sha1hash = filehash(jar_path, hashlib.sha1)
  267. installer_info.sha256hash = filehash(jar_path, hashlib.sha256)
  268. installer_info.size = os.path.getsize(jar_path)
  269. installer_info.write(installer_info_path)
  270. else:
  271. # ignore the two versions without install manifests and jar mod class files
  272. # TODO: fix those versions?
  273. if version.mc_version_sane == "1.6.1":
  274. continue
  275. # only gather legacy info if it's missing
  276. if not os.path.isfile(LEGACYINFO_PATH):
  277. # grab the jar/zip if it's not there
  278. if not os.path.isfile(jar_path):
  279. rfile = sess.get(version.url(), stream=True)
  280. rfile.raise_for_status()
  281. with open(jar_path, 'wb') as f:
  282. for chunk in rfile.iter_content(chunk_size=128):
  283. f.write(chunk)
  284. # find the latest timestamp in the zip file
  285. tstamp = datetime.fromtimestamp(0)
  286. with zipfile.ZipFile(jar_path) as jar:
  287. for info in jar.infolist():
  288. tstamp_new = datetime(*info.date_time)
  289. if tstamp_new > tstamp:
  290. tstamp = tstamp_new
  291. legacy_info = ForgeLegacyInfo()
  292. legacy_info.release_time = tstamp
  293. legacy_info.sha1 = filehash(jar_path, hashlib.sha1)
  294. legacy_info.sha256 = filehash(jar_path, hashlib.sha256)
  295. legacy_info.size = os.path.getsize(jar_path)
  296. legacy_info_list.number[key] = legacy_info
  297. # only write legacy info if it's missing
  298. if not os.path.isfile(LEGACYINFO_PATH):
  299. legacy_info_list.write(LEGACYINFO_PATH)
  300. if __name__ == '__main__':
  301. main()