1#!/usr/bin/env nix-shell
2#! nix-shell -i python -p "python3.withPackages (ps: with ps; [ ps.absl-py ps.requests ])"
3
4from collections import defaultdict
5import copy
6from dataclasses import dataclass
7import json
8import os.path
9from typing import Callable, Dict
10
11from absl import app
12from absl import flags
13from absl import logging
14import requests
15
16
17FACTORIO_RELEASES = "https://factorio.com/api/latest-releases"
18FACTORIO_HASHES = "https://factorio.com/download/sha256sums/"
19
20
21FLAGS = flags.FLAGS
22
23flags.DEFINE_string("out", "", "Output path for versions.json.")
24flags.DEFINE_list(
25 "release_type",
26 "",
27 "If non-empty, a comma-separated list of release types to update (e.g. alpha).",
28)
29flags.DEFINE_list(
30 "release_channel",
31 "",
32 "If non-empty, a comma-separated list of release channels to update (e.g. experimental).",
33)
34
35
36@dataclass
37class System:
38 nix_name: str
39 url_name: str
40 tar_name: str
41
42
43@dataclass
44class ReleaseType:
45 name: str
46 hash_filename_format: list[str]
47 needs_auth: bool = False
48
49
50@dataclass
51class ReleaseChannel:
52 name: str
53
54
55FactorioVersionsJSON = Dict[str, Dict[str, str]]
56OurVersionJSON = Dict[str, Dict[str, Dict[str, Dict[str, str]]]]
57
58FactorioHashes = Dict[str, str]
59
60
61SYSTEMS = [
62 System(nix_name="x86_64-linux", url_name="linux64", tar_name="x64"),
63]
64
65RELEASE_TYPES = [
66 ReleaseType(
67 "alpha",
68 needs_auth=True,
69 hash_filename_format=["factorio_linux_{version}.tar.xz"],
70 ),
71 ReleaseType("demo", hash_filename_format=["factorio-demo_linux_{version}.tar.xz"]),
72 ReleaseType(
73 "headless",
74 hash_filename_format=[
75 "factorio-headless_linux_{version}.tar.xz",
76 "factorio_headless_x64_{version}.tar.xz",
77 ],
78 ),
79 ReleaseType(
80 "expansion",
81 needs_auth=True,
82 hash_filename_format=["factorio-space-age_linux_{version}.tar.xz"],
83 ),
84]
85
86RELEASE_CHANNELS = [
87 ReleaseChannel("experimental"),
88 ReleaseChannel("stable"),
89]
90
91
92def find_versions_json() -> str:
93 if FLAGS.out:
94 return FLAGS.out
95 try_paths = ["pkgs/by-name/fa/factorio/versions.json", "versions.json"]
96 for path in try_paths:
97 if os.path.exists(path):
98 return path
99 raise Exception(
100 "Couldn't figure out where to write versions.json; try specifying --out"
101 )
102
103
104def fetch_versions() -> FactorioVersionsJSON:
105 return json.loads(requests.get(FACTORIO_RELEASES).text)
106
107
108def fetch_hashes() -> FactorioHashes:
109 resp = requests.get(FACTORIO_HASHES)
110 resp.raise_for_status()
111 out = {}
112 for ln in resp.text.split("\n"):
113 ln = ln.strip()
114 if not ln:
115 continue
116 sha256, filename = ln.split()
117 out[filename] = sha256
118 return out
119
120
121def generate_our_versions(factorio_versions: FactorioVersionsJSON) -> OurVersionJSON:
122 def rec_dd():
123 return defaultdict(rec_dd)
124
125 output = rec_dd()
126
127 # Deal with times where there's no experimental version
128 for rc in RELEASE_CHANNELS:
129 if rc.name not in factorio_versions or not factorio_versions[rc.name]:
130 factorio_versions[rc.name] = factorio_versions["stable"]
131 for rt in RELEASE_TYPES:
132 if (
133 rt.name not in factorio_versions[rc.name]
134 or not factorio_versions[rc.name][rt.name]
135 ):
136 factorio_versions[rc.name][rt.name] = factorio_versions["stable"][
137 rt.name
138 ]
139
140 for system in SYSTEMS:
141 for release_type in RELEASE_TYPES:
142 for release_channel in RELEASE_CHANNELS:
143 version = factorio_versions[release_channel.name].get(release_type.name)
144 if version is None:
145 continue
146 this_release = {
147 "name": f"factorio_{release_type.name}_{system.tar_name}-{version}.tar.xz",
148 "url": f"https://factorio.com/get-download/{version}/{release_type.name}/{system.url_name}",
149 "version": version,
150 "needsAuth": release_type.needs_auth,
151 "candidateHashFilenames": [
152 fmt.format(version=version)
153 for fmt in release_type.hash_filename_format
154 ],
155 "tarDirectory": system.tar_name,
156 }
157 output[system.nix_name][release_type.name][release_channel.name] = (
158 this_release
159 )
160 return output
161
162
163def iter_version(
164 versions: OurVersionJSON,
165 it: Callable[[str, str, str, Dict[str, str]], Dict[str, str]],
166) -> OurVersionJSON:
167 versions = copy.deepcopy(versions)
168 for system_name, system in versions.items():
169 for release_type_name, release_type in system.items():
170 for release_channel_name, release in release_type.items():
171 release_type[release_channel_name] = it(
172 system_name, release_type_name, release_channel_name, dict(release)
173 )
174 return versions
175
176
177def merge_versions(old: OurVersionJSON, new: OurVersionJSON) -> OurVersionJSON:
178 """Copies already-known hashes from version.json to avoid having to re-fetch."""
179
180 def _merge_version(
181 system_name: str,
182 release_type_name: str,
183 release_channel_name: str,
184 release: Dict[str, str],
185 ) -> Dict[str, str]:
186 old_system = old.get(system_name, {})
187 old_release_type = old_system.get(release_type_name, {})
188 old_release = old_release_type.get(release_channel_name, {})
189 if FLAGS.release_type and release_type_name not in FLAGS.release_type:
190 logging.info(
191 "%s/%s/%s: not in --release_type, not updating",
192 system_name,
193 release_type_name,
194 release_channel_name,
195 )
196 return old_release
197 if FLAGS.release_channel and release_channel_name not in FLAGS.release_channel:
198 logging.info(
199 "%s/%s/%s: not in --release_channel, not updating",
200 system_name,
201 release_type_name,
202 release_channel_name,
203 )
204 return old_release
205 if "sha256" not in old_release:
206 logging.info(
207 "%s/%s/%s: not copying sha256 since it's missing",
208 system_name,
209 release_type_name,
210 release_channel_name,
211 )
212 return release
213 if not all(
214 old_release.get(k, None) == release[k] for k in ["name", "version", "url"]
215 ):
216 logging.info(
217 "%s/%s/%s: not copying sha256 due to mismatch",
218 system_name,
219 release_type_name,
220 release_channel_name,
221 )
222 return release
223 release["sha256"] = old_release["sha256"]
224 return release
225
226 return iter_version(new, _merge_version)
227
228
229def fill_in_hash(
230 versions: OurVersionJSON, factorio_hashes: FactorioHashes
231) -> OurVersionJSON:
232 """Fill in sha256 hashes for anything missing them."""
233
234 def _fill_in_hash(
235 system_name: str,
236 release_type_name: str,
237 release_channel_name: str,
238 release: Dict[str, str],
239 ) -> Dict[str, str]:
240 for candidate_filename in release["candidateHashFilenames"]:
241 if candidate_filename in factorio_hashes:
242 release["sha256"] = factorio_hashes[candidate_filename]
243 break
244 else:
245 logging.error(
246 "%s/%s/%s: failed to find any of %s in %s",
247 system_name,
248 release_type_name,
249 release_channel_name,
250 release["candidateHashFilenames"],
251 FACTORIO_HASHES,
252 )
253 return release
254 if "sha256" in release:
255 logging.info(
256 "%s/%s/%s: skipping fetch, sha256 already present",
257 system_name,
258 release_type_name,
259 release_channel_name,
260 )
261 return release
262 return release
263
264 return iter_version(versions, _fill_in_hash)
265
266
267def main(argv):
268 factorio_versions = fetch_versions()
269 factorio_hashes = fetch_hashes()
270 new_our_versions = generate_our_versions(factorio_versions)
271 old_our_versions = None
272 our_versions_path = find_versions_json()
273 if our_versions_path:
274 logging.info("Loading old versions.json from %s", our_versions_path)
275 with open(our_versions_path, "r") as f:
276 old_our_versions = json.load(f)
277 if old_our_versions:
278 logging.info("Merging in old hashes")
279 new_our_versions = merge_versions(old_our_versions, new_our_versions)
280 logging.info("Updating hashes from Factorio SHA256")
281 new_our_versions = fill_in_hash(new_our_versions, factorio_hashes)
282 with open(our_versions_path, "w") as f:
283 logging.info("Writing versions.json to %s", our_versions_path)
284 json.dump(new_our_versions, f, sort_keys=True, indent=2)
285 f.write("\n")
286
287
288if __name__ == "__main__":
289 app.run(main)