fz-manager-plus 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. fz_manager_plus/__init__.py +7 -0
  2. fz_manager_plus/application/__init__.py +1 -0
  3. fz_manager_plus/application/ports.py +35 -0
  4. fz_manager_plus/application/session.py +455 -0
  5. fz_manager_plus/application/transfers.py +56 -0
  6. fz_manager_plus/cli.py +5 -0
  7. fz_manager_plus/config.py +134 -0
  8. fz_manager_plus/domain/__init__.py +1 -0
  9. fz_manager_plus/domain/errors.py +24 -0
  10. fz_manager_plus/domain/messages.py +116 -0
  11. fz_manager_plus/domain/state.py +83 -0
  12. fz_manager_plus/infrastructure/__init__.py +0 -0
  13. fz_manager_plus/infrastructure/factorio_zone/__init__.py +0 -0
  14. fz_manager_plus/infrastructure/factorio_zone/client.py +194 -0
  15. fz_manager_plus/infrastructure/factorio_zone/mods.py +43 -0
  16. fz_manager_plus/infrastructure/factorio_zone/session.py +4 -0
  17. fz_manager_plus/infrastructure/factorio_zone/socket.py +34 -0
  18. fz_manager_plus/runtime.py +16 -0
  19. fz_manager_plus/terminal.py +45 -0
  20. fz_manager_plus/tui/__init__.py +0 -0
  21. fz_manager_plus/tui/app.py +409 -0
  22. fz_manager_plus/tui/components/__init__.py +35 -0
  23. fz_manager_plus/tui/components/choice_screen.py +59 -0
  24. fz_manager_plus/tui/components/confirm_screen.py +52 -0
  25. fz_manager_plus/tui/components/log_pane.py +43 -0
  26. fz_manager_plus/tui/components/menu_pane.py +36 -0
  27. fz_manager_plus/tui/components/mods_pane.py +100 -0
  28. fz_manager_plus/tui/components/mods_upload_screen.py +155 -0
  29. fz_manager_plus/tui/components/multi_choice_screen.py +83 -0
  30. fz_manager_plus/tui/components/saves_pane.py +72 -0
  31. fz_manager_plus/tui/components/selectable_list.py +51 -0
  32. fz_manager_plus/tui/components/status_bar.py +25 -0
  33. fz_manager_plus/tui/components/token_screen.py +40 -0
  34. fz_manager_plus/tui/flows/__init__.py +13 -0
  35. fz_manager_plus/tui/flows/host.py +26 -0
  36. fz_manager_plus/tui/flows/mod_flows.py +29 -0
  37. fz_manager_plus/tui/flows/save_flows.py +57 -0
  38. fz_manager_plus/tui/flows/server_flows.py +68 -0
  39. fz_manager_plus/tui/flows/sync_flows.py +45 -0
  40. fz_manager_plus/tui/progress.py +26 -0
  41. fz_manager_plus/tui/run.py +4 -0
  42. fz_manager_plus/utils/__init__.py +0 -0
  43. fz_manager_plus/utils/api_router/__init__.py +0 -0
  44. fz_manager_plus/utils/api_router/http.py +86 -0
  45. fz_manager_plus/utils/api_router/ws.py +67 -0
  46. fz_manager_plus/utils/async_io.py +18 -0
  47. fz_manager_plus/utils/concurrency.py +17 -0
  48. fz_manager_plus/utils/files.py +34 -0
  49. fz_manager_plus-0.1.2.dist-info/METADATA +16 -0
  50. fz_manager_plus-0.1.2.dist-info/RECORD +53 -0
  51. fz_manager_plus-0.1.2.dist-info/WHEEL +4 -0
  52. fz_manager_plus-0.1.2.dist-info/entry_points.txt +3 -0
  53. fz_manager_plus-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("fz-manager-plus")
5
+ except PackageNotFoundError:
6
+ # Package is not installed (e.g. running from a source checkout without `uv sync`).
7
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1 @@
1
+ """Application services, independent of the terminal UI."""
@@ -0,0 +1,35 @@
1
+ from collections.abc import AsyncIterator, Callable
2
+ from typing import BinaryIO, Protocol
3
+
4
+ from fz_manager_plus.domain.messages import BlankMessage, FzMessage, LoginResponse
5
+
6
+ type Progress = Callable[[int], None] | None
7
+
8
+
9
+ class ZoneAPI(Protocol):
10
+ visit_secret: str | None
11
+ user_token: str | None
12
+
13
+ async def login(self, reconnected: bool = False) -> LoginResponse: ...
14
+ async def start_instance(self, region: str, version: str, save: str) -> None: ...
15
+ async def stop_instance(self, launch_id: int) -> None: ...
16
+ async def send_command(self, launch_id: int, command: str) -> None: ...
17
+ async def toggle_mod(self, mod_id: int, enabled: bool) -> None: ...
18
+ async def delete_mod(self, mod_id: int) -> None: ...
19
+ async def upload_mod(
20
+ self, name: str, file: BinaryIO, size: int, progress: Progress = None
21
+ ) -> None: ...
22
+ async def upload_save(
23
+ self, name: str, file: BinaryIO, size: int, slot: str, progress: Progress = None
24
+ ) -> None: ...
25
+ async def delete_save_slot(self, slot: str) -> None: ...
26
+ async def download_save_slot(
27
+ self, slot: str, file_path: str, progress: Progress = None
28
+ ) -> None: ...
29
+ async def aclose(self) -> None: ...
30
+
31
+
32
+ class ZoneSocket(Protocol):
33
+ async def connect(self) -> None: ...
34
+ def messages(self) -> AsyncIterator[FzMessage | BlankMessage]: ...
35
+ async def close(self) -> None: ...
@@ -0,0 +1,455 @@
1
+ import asyncio
2
+ import logging
3
+ import re
4
+ from collections import OrderedDict
5
+ from collections.abc import Awaitable, Callable
6
+ from contextlib import asynccontextmanager, suppress
7
+ from inspect import isawaitable
8
+
9
+ from fz_manager_plus.application.ports import ZoneAPI, ZoneSocket
10
+ from fz_manager_plus.config import Settings
11
+ from fz_manager_plus.domain.errors import AuthenticationError, DisconnectedError, OperationError
12
+ from fz_manager_plus.domain.messages import (
13
+ BlankMessage,
14
+ ConsoleMessage,
15
+ ErrorMessage,
16
+ IdleMessage,
17
+ InfoMessage,
18
+ LogMessage,
19
+ ModsMessage,
20
+ OptionsMessage,
21
+ RunningMessage,
22
+ SlotMessage,
23
+ StartingMessage,
24
+ StoppingMessage,
25
+ VisitMessage,
26
+ WarnMessage,
27
+ )
28
+ from fz_manager_plus.domain.state import (
29
+ AuthenticationRequired,
30
+ ConnectionStatus,
31
+ LogEvent,
32
+ Mod,
33
+ ServerStatus,
34
+ SessionEvent,
35
+ SessionState,
36
+ StateChanged,
37
+ TokenReceived,
38
+ )
39
+
40
+ type Listener = Callable[[SessionEvent], Awaitable[None] | None]
41
+ _ADDRESS = re.compile(r"selecting connection (\d+\.\d+\.\d+\.\d+:\d+)")
42
+ _LOG_LIMIT = 10_000
43
+ _logger = logging.getLogger(__name__)
44
+
45
+
46
+ class FactorioZoneSession:
47
+ def __init__(self, api: ZoneAPI, socket: ZoneSocket, settings: Settings):
48
+ self.api, self.socket, self.settings = api, socket, settings
49
+ self.state = SessionState()
50
+ self.user_token = api.user_token
51
+ self._listeners: list[Listener] = []
52
+ self._changed = asyncio.Event()
53
+ self._disconnected = asyncio.Event()
54
+ self._disconnected.set()
55
+ self._generation = 0
56
+ self._closed = False
57
+ self._runner: asyncio.Task | None = None
58
+ self._operations: set[asyncio.Task] = set()
59
+ self._logs: OrderedDict[tuple[object, int], None] = OrderedDict()
60
+ self._locks: dict[str, asyncio.Lock] = {}
61
+ self._ever_authenticated = False
62
+ self._close_task: asyncio.Task | None = None
63
+
64
+ @property
65
+ def regions(self):
66
+ return self.state.regions
67
+
68
+ @property
69
+ def versions(self):
70
+ return self.state.versions
71
+
72
+ @property
73
+ def saves(self):
74
+ return self.state.saves
75
+
76
+ @property
77
+ def mods(self):
78
+ return self.state.mods
79
+
80
+ @property
81
+ def launch_id(self):
82
+ return self.state.launch_id
83
+
84
+ @property
85
+ def server_status(self):
86
+ return self.state.server_status
87
+
88
+ @property
89
+ def server_address(self):
90
+ return self.state.server_address
91
+
92
+ def subscribe(self, listener: Listener) -> None:
93
+ if listener not in self._listeners:
94
+ self._listeners.append(listener)
95
+
96
+ def unsubscribe(self, listener: Listener) -> None:
97
+ if listener in self._listeners:
98
+ self._listeners.remove(listener)
99
+
100
+ async def emit(self, event: SessionEvent) -> None:
101
+ for listener in tuple(self._listeners):
102
+ try:
103
+ result = listener(event)
104
+ if isawaitable(result):
105
+ await result
106
+ except Exception:
107
+ # A broken observer must not tear down the transport.
108
+ _logger.exception("Session event listener failed")
109
+
110
+ async def _publish_state(self) -> None:
111
+ self.state.revision += 1
112
+ previous, self._changed = self._changed, asyncio.Event()
113
+ previous.set()
114
+ await self.emit(StateChanged(self.state.revision))
115
+
116
+ async def _disconnect(self, status=ConnectionStatus.DISCONNECTED) -> None:
117
+ self._disconnected.set()
118
+ self.state.ready.clear()
119
+ self.state.connection = status
120
+ self.api.visit_secret = None
121
+ await self._publish_state()
122
+
123
+ async def connect(self) -> None:
124
+ if self._closed:
125
+ raise DisconnectedError("Session is closed")
126
+ self._generation += 1
127
+ self._disconnected = asyncio.Event()
128
+ self.state.ready.clear()
129
+ self.state.launch_id = None
130
+ self.state.server_address = None
131
+ self.state.connection = ConnectionStatus.CONNECTING
132
+ await self._publish_state()
133
+ async with asyncio.timeout(self.settings.sync_timeout):
134
+ await self.socket.connect()
135
+ self.state.connection = ConnectionStatus.SYNCING
136
+ await self._publish_state()
137
+
138
+ async def _consume(self) -> None:
139
+ async for message in self.socket.messages():
140
+ await self.handle_message(message)
141
+ raise DisconnectedError("Connection closed by the server")
142
+
143
+ async def _connected_run(self) -> None:
144
+ consumer = asyncio.create_task(self._consume())
145
+ ready = asyncio.create_task(self.wait_ready("auth", "mods", "saves", "server"))
146
+ try:
147
+ completed, _ = await asyncio.wait(
148
+ (consumer, ready), return_when=asyncio.FIRST_COMPLETED
149
+ )
150
+ if consumer in completed:
151
+ await consumer
152
+ await ready
153
+ await consumer
154
+ finally:
155
+ for task in (consumer, ready):
156
+ task.cancel()
157
+ await asyncio.gather(consumer, ready, return_exceptions=True)
158
+
159
+ async def run(self) -> None:
160
+ if self._runner is not None:
161
+ raise RuntimeError("Session is already running")
162
+ self._runner = asyncio.current_task()
163
+ delay = 1
164
+ try:
165
+ while not self._closed:
166
+ try:
167
+ await self.connect()
168
+ await self._connected_run()
169
+ except AuthenticationError as error:
170
+ await self._disconnect(ConnectionStatus.AUTH_REQUIRED)
171
+ await self.emit(AuthenticationRequired(str(error)))
172
+ return
173
+ except (DisconnectedError, OperationError, OSError, TimeoutError) as error:
174
+ was_ready = self.state.connection == ConnectionStatus.CONNECTED
175
+ await self._disconnect()
176
+ await self.emit(
177
+ LogEvent(
178
+ f"Connection lost: {error}. Retrying in {1 if was_ready else delay}s.",
179
+ "warn",
180
+ )
181
+ )
182
+ if was_ready:
183
+ delay = 1
184
+ finally:
185
+ with suppress(Exception):
186
+ await self.socket.close()
187
+ if not self._closed:
188
+ await asyncio.sleep(delay)
189
+ delay = min(delay * 2, 30)
190
+ finally:
191
+ if self.state.connection != ConnectionStatus.AUTH_REQUIRED:
192
+ await self._disconnect()
193
+ self._runner = None
194
+
195
+ async def wait_for(self, predicate: Callable[[SessionState], bool], timeout: float) -> None:
196
+ generation = self._generation
197
+ async with asyncio.timeout(timeout):
198
+ while True:
199
+ changed = self._changed
200
+ if self._closed or self._disconnected.is_set() or generation != self._generation:
201
+ raise DisconnectedError("Connection was interrupted; operation was not retried")
202
+ if predicate(self.state):
203
+ return
204
+ await changed.wait()
205
+
206
+ async def wait_ready(self, *parts: str) -> None:
207
+ await self.wait_for(lambda state: set(parts) <= state.ready, self.settings.sync_timeout)
208
+
209
+ def require_ready(self, *parts: str) -> None:
210
+ if self._closed or self._disconnected.is_set() or not {"auth", *parts} <= self.state.ready:
211
+ raise DisconnectedError("Connection is not ready; wait for synchronization")
212
+
213
+ async def _call[T](self, operation: Callable[[], Awaitable[T]]) -> T:
214
+ self.require_ready()
215
+ disconnected = self._disconnected
216
+
217
+ async def execute() -> T:
218
+ return await operation()
219
+
220
+ task = asyncio.create_task(execute())
221
+ lost = asyncio.create_task(disconnected.wait())
222
+ self._operations.add(task)
223
+ try:
224
+ await asyncio.wait((task, lost), return_when=asyncio.FIRST_COMPLETED)
225
+ if disconnected.is_set():
226
+ raise DisconnectedError("Connection was interrupted; operation was not retried")
227
+ return await task
228
+ finally:
229
+ task.cancel()
230
+ lost.cancel()
231
+ await asyncio.gather(task, lost, return_exceptions=True)
232
+ self._operations.discard(task)
233
+
234
+ @asynccontextmanager
235
+ async def operation(self, resource: str, *ready: str):
236
+ self.require_ready(*ready)
237
+ generation = self._generation
238
+ lock = self._locks.setdefault(resource, asyncio.Lock())
239
+ async with lock:
240
+ if generation != self._generation:
241
+ raise DisconnectedError("Connection changed while the operation was waiting")
242
+ self.require_ready(*ready)
243
+ yield
244
+
245
+ async def start_instance(self, region: str, version: str, save: str) -> None:
246
+ async with self.operation("server", "server", "regions", "versions", "saves"):
247
+ if self.state.server_status != ServerStatus.OFFLINE:
248
+ raise OperationError("Server is already active")
249
+ if region not in self.regions or version not in self.versions or save not in self.saves:
250
+ raise OperationError("Server options changed; choose them again")
251
+ async with asyncio.timeout(self.settings.start_timeout):
252
+ await self._call(lambda: self.api.start_instance(region, version, save))
253
+ await self.wait_for(
254
+ lambda state: (
255
+ state.server_status == ServerStatus.RUNNING and bool(state.server_address)
256
+ ),
257
+ self.settings.start_timeout,
258
+ )
259
+
260
+ async def stop_instance(self) -> None:
261
+ async with self.operation("server", "server"):
262
+ launch_id = self._launch_id()
263
+ async with asyncio.timeout(self.settings.stop_timeout):
264
+ await self._call(lambda: self.api.stop_instance(launch_id))
265
+ await self.wait_for(
266
+ lambda state: state.server_status == ServerStatus.OFFLINE,
267
+ self.settings.stop_timeout,
268
+ )
269
+
270
+ def _launch_id(self) -> int:
271
+ launch_id = self.launch_id
272
+ if launch_id is None:
273
+ raise OperationError("No running server")
274
+ return launch_id
275
+
276
+ async def send_command(self, command: str) -> None:
277
+ self.require_ready("server")
278
+ if self.server_status != ServerStatus.RUNNING:
279
+ raise OperationError("Server is not running yet")
280
+ launch_id = self._launch_id()
281
+ await self._call(lambda: self.api.send_command(launch_id, command))
282
+
283
+ async def toggle_mod(self, mod_id: int, enabled: bool) -> None:
284
+ async with self.operation("mods", "mods"):
285
+ await self._call(lambda: self.api.toggle_mod(mod_id, enabled))
286
+
287
+ @asynccontextmanager
288
+ async def mod_uploads(self):
289
+ """Reserve mod mutations for a batch; uploads inside may run concurrently."""
290
+ async with self.operation("mods", "mods"):
291
+ generation = self._generation
292
+
293
+ async def upload(name, file, size, progress=None):
294
+ if generation != self._generation:
295
+ raise DisconnectedError("Connection changed; upload was not retried")
296
+ await self._call(lambda: self.api.upload_mod(name, file, size, progress))
297
+
298
+ yield upload
299
+
300
+ async def delete_mod(self, mod_id: int) -> None:
301
+ async with self.operation("mods", "mods"):
302
+ await self._call(lambda: self.api.delete_mod(mod_id))
303
+
304
+ async def delete_all_mods(self) -> None:
305
+ async with self.operation("mods", "mods"):
306
+ for mod in tuple(self.mods):
307
+ try:
308
+ await self._call(lambda entry=mod: self.api.delete_mod(entry.id))
309
+ await self.emit(LogEvent(f"Deleted {mod.text}", "info"))
310
+ except DisconnectedError:
311
+ raise
312
+ except OperationError as error:
313
+ await self.emit(LogEvent(f"{mod.text}: {error}", "error"))
314
+
315
+ def used_save_slots(self) -> list[tuple[int, str]]:
316
+ return sorted(
317
+ (int(slot.removeprefix("slot")), description)
318
+ for slot, description in self.saves.items()
319
+ if re.fullmatch(r"slot\d+", slot)
320
+ and description
321
+ and not description.endswith("(empty)")
322
+ )
323
+
324
+ def is_save_slot_used(self, slot_index: int) -> bool:
325
+ description = self.saves.get(f"slot{slot_index}")
326
+ return description is not None and bool(description) and not description.endswith("(empty)")
327
+
328
+ def _check_slot(self, slot: str, *, used: bool = False) -> None:
329
+ if slot not in self.saves or not re.fullmatch(r"slot\d+", slot):
330
+ raise OperationError("Unknown save slot")
331
+ if used and not self.is_save_slot_used(int(slot.removeprefix("slot"))):
332
+ raise OperationError("Save slot is empty")
333
+
334
+ async def upload_save(self, name, file, size, slot, progress=None) -> None:
335
+ async with self.operation(f"save:{slot}", "saves"):
336
+ self._check_slot(slot)
337
+ await self._call(lambda: self.api.upload_save(name, file, size, slot, progress))
338
+
339
+ async def delete_save_slot(self, slot: str) -> None:
340
+ async with self.operation(f"save:{slot}", "saves"):
341
+ self._check_slot(slot, used=True)
342
+ await self._call(lambda: self.api.delete_save_slot(slot))
343
+
344
+ async def download_save_slot(self, slot: str, file_path: str, progress=None) -> None:
345
+ async with self.operation(f"save:{slot}", "saves"):
346
+ self._check_slot(slot, used=True)
347
+ await self._call(lambda: self.api.download_save_slot(slot, file_path, progress))
348
+
349
+ async def handle_message(self, message) -> None:
350
+ state = self.state
351
+ match message:
352
+ case VisitMessage():
353
+ self.api.visit_secret = message.secret
354
+ response = await self.api.login(reconnected=self._ever_authenticated)
355
+ self._ever_authenticated = True
356
+ self.user_token = response.user_token
357
+ self.api.user_token = response.user_token
358
+ state.ready.add("auth")
359
+ await self.emit(TokenReceived(response.user_token))
360
+ case OptionsMessage():
361
+ options = message.options
362
+ if message.name in ("regions", "saves"):
363
+ if not isinstance(options, dict) or not all(
364
+ isinstance(k, str) and isinstance(v, str) for k, v in options.items()
365
+ ):
366
+ await self.emit(LogEvent(f"Invalid {message.name} options", "warn"))
367
+ return
368
+ setattr(state, message.name, dict(options))
369
+ state.ready.add(message.name)
370
+ elif message.name == "versions":
371
+ if not isinstance(options, (dict, list)) or not all(
372
+ isinstance(v, str) for v in options
373
+ ):
374
+ await self.emit(LogEvent("Invalid version options", "warn"))
375
+ return
376
+ state.versions = tuple(options)
377
+ state.ready.add("versions")
378
+ case ModsMessage():
379
+ state.mods = tuple(Mod(mod.id, mod.text, mod.enabled) for mod in message.mods)
380
+ state.ready.add("mods")
381
+ case IdleMessage():
382
+ state.server_status = ServerStatus.OFFLINE
383
+ state.launch_id = state.server_address = None
384
+ state.ready.add("server")
385
+ case StartingMessage() | StoppingMessage() | RunningMessage():
386
+ previous = state.launch_id
387
+ if message.launch_id is not None:
388
+ state.launch_id = message.launch_id
389
+ if previous != state.launch_id:
390
+ state.server_address = None
391
+ state.server_status = {
392
+ "starting": ServerStatus.STARTING,
393
+ "stopping": ServerStatus.STOPPING,
394
+ "running": ServerStatus.RUNNING,
395
+ }[message.type]
396
+ if isinstance(message, RunningMessage):
397
+ state.server_address = message.socket or state.server_address
398
+ state.ready.add("server")
399
+ case LogMessage():
400
+ launch = message.launch_id if message.launch_id is not None else state.launch_id
401
+ key = (
402
+ launch if launch is not None else f"connection:{self._generation}",
403
+ message.num,
404
+ )
405
+ if key in self._logs:
406
+ return
407
+ self._logs[key] = None
408
+ if len(self._logs) > _LOG_LIMIT:
409
+ self._logs.popitem(last=False)
410
+ await self.emit(LogEvent(message.line or ""))
411
+ if (
412
+ state.launch_id is None
413
+ and message.launch_id is not None
414
+ and state.server_status != ServerStatus.OFFLINE
415
+ ):
416
+ state.launch_id = message.launch_id
417
+ await self._publish_state()
418
+ return
419
+ case InfoMessage() | WarnMessage() | ErrorMessage():
420
+ line = message.line or ""
421
+ if isinstance(message, InfoMessage) and (found := _ADDRESS.search(line)):
422
+ state.server_address = found[1]
423
+ await self.emit(LogEvent(line, message.type))
424
+ case BlankMessage():
425
+ await self.emit(LogEvent(f"Unrecognized WS message type: {message.type}", "warn"))
426
+ return
427
+ case ConsoleMessage() | SlotMessage():
428
+ # Console is an acknowledgement; save labels arrive through options.
429
+ return
430
+ case _:
431
+ return
432
+ if {"auth", "server", "mods", "saves"} <= state.ready:
433
+ state.connection = ConnectionStatus.CONNECTED
434
+ await self._publish_state()
435
+
436
+ async def aclose(self) -> None:
437
+ task = self._close_task
438
+ if task is None:
439
+ task = self._close_task = asyncio.create_task(self._close())
440
+ await asyncio.shield(task)
441
+
442
+ async def _close(self) -> None:
443
+ self._closed = True
444
+ await self._disconnect()
445
+ tasks = list(self._operations)
446
+ runner = self._runner
447
+ if runner is not None and runner is not asyncio.current_task():
448
+ tasks.append(runner)
449
+ for task in tasks:
450
+ task.cancel()
451
+ await asyncio.gather(*tasks, return_exceptions=True)
452
+ try:
453
+ await self.socket.close()
454
+ finally:
455
+ await self.api.aclose()
@@ -0,0 +1,56 @@
1
+ from collections.abc import Callable
2
+ from pathlib import Path
3
+
4
+ from fz_manager_plus.application.session import FactorioZoneSession
5
+ from fz_manager_plus.domain.state import TransferEvent, TransferProgress, TransferResult, UploadItem
6
+ from fz_manager_plus.utils.async_io import blocking_io
7
+ from fz_manager_plus.utils.concurrency import run_batched
8
+ from fz_manager_plus.utils.files import find_by_extension
9
+
10
+
11
+ class ModTransferService:
12
+ def __init__(self, session: FactorioZoneSession):
13
+ self.session = session
14
+
15
+ async def prepare(self, directory: str) -> list[UploadItem]:
16
+ self.session.require_ready("mods")
17
+ files = await blocking_io(find_by_extension, directory, ".zip")
18
+ names = {mod.text for mod in self.session.mods}
19
+
20
+ def inspect_files():
21
+ return [
22
+ UploadItem(name, Path(file).stat().st_size, Path(file))
23
+ for name, file in sorted(files.items())
24
+ if name not in names
25
+ ]
26
+
27
+ return await blocking_io(inspect_files)
28
+
29
+ async def upload(
30
+ self, items: list[UploadItem], notify: Callable[[TransferEvent], None]
31
+ ) -> list[str]:
32
+ failed: list[str] = []
33
+ async with self.session.mod_uploads() as upload:
34
+
35
+ async def run_one(index: int) -> None:
36
+ item = items[index]
37
+ try:
38
+ self.session.require_ready("mods")
39
+ # Metadata open/close are short; all payload reads happen in the transport thread.
40
+ with item.path.open("rb") as stream:
41
+ await upload(
42
+ item.label,
43
+ stream,
44
+ item.size,
45
+ lambda done: notify(TransferProgress(index, done)),
46
+ )
47
+ notify(TransferResult(index))
48
+ except Exception as error:
49
+ failed.append(item.label)
50
+ notify(TransferResult(index, str(error) or type(error).__name__))
51
+
52
+ await run_batched(
53
+ [lambda i=i: run_one(i) for i in range(len(items))],
54
+ self.session.settings.sync_batch_size,
55
+ )
56
+ return failed
fz_manager_plus/cli.py ADDED
@@ -0,0 +1,5 @@
1
+ from fz_manager_plus.tui.app import main as run_tui
2
+
3
+
4
+ def main() -> None:
5
+ run_tui()