Bombsquad-Ballistica-Modded.../dist/ba_data/python/bacommon/transfer.py

133 lines
4.6 KiB
Python
Raw Normal View History

2022-06-30 00:31:52 +05:30
# Released under the MIT License. See LICENSE for details.
#
2026-02-15 14:00:00 +05:30
"""Functionality related to transferring files/data.
.. warning::
This is an internal api and subject to change at any time. Do not use
it in mod code.
"""
2022-06-30 00:31:52 +05:30
import os
2022-07-16 17:59:14 +05:30
from pathlib import Path
2022-06-30 00:31:52 +05:30
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
2022-07-16 17:59:14 +05:30
pass
2022-06-30 00:31:52 +05:30
@ioprepped
@dataclass
class DirectoryManifestFile:
2024-03-10 15:37:50 +05:30
"""Describes a file in a manifest."""
2024-03-10 15:37:50 +05:30
hash_sha256: Annotated[str, IOAttrs('h')]
size: Annotated[int, IOAttrs('s')]
2022-06-30 00:31:52 +05:30
@ioprepped
@dataclass
class DirectoryManifest:
"""Contains a summary of files in a directory."""
2022-06-30 00:31:52 +05:30
files: Annotated[dict[str, DirectoryManifestFile], IOAttrs('f')]
2024-05-19 18:25:43 +05:30
# Soft-default added April 2024; can remove eventually once this
# attr is widespread in client.
exists: Annotated[bool, IOAttrs('e', soft_default=True)]
2022-06-30 00:31:52 +05:30
@classmethod
def create_from_disk(cls, path: Path) -> DirectoryManifest:
"""Create a manifest from a directory on disk."""
import hashlib
from concurrent.futures import ThreadPoolExecutor
pathstr = str(path)
2026-06-28 18:53:43 +05:30
# Each entry pairs the manifest key (forward-slashed; for a
# single file the bare path.as_posix() the put-file handler
# keys on) with the actual on-disk path to hash. Keeping the two
# separate is what fixes single-file inputs: the dir case joins
# a relative key back onto pathstr, but doing that to a
# single-file key (which is the whole path) doubles a relative
# path up (a/b.png -> a/b.png/a/b.png -> File not found).
entries: list[tuple[str, str]] = []
2022-06-30 00:31:52 +05:30
2024-05-19 18:25:43 +05:30
exists = path.exists()
2022-06-30 00:31:52 +05:30
if path.is_dir():
2022-07-16 17:59:14 +05:30
# Build the full list of relative paths.
2022-06-30 00:31:52 +05:30
for basename, _dirnames, filenames in os.walk(path):
for filename in filenames:
fullname = os.path.join(basename, filename)
assert fullname.startswith(pathstr)
2022-07-16 17:59:14 +05:30
# Make sure we end up with forward slashes no matter
# what the os.* stuff above here was using.
2026-06-28 18:53:43 +05:30
key = Path(fullname[len(pathstr) + 1 :]).as_posix()
entries.append((key, fullname))
2024-05-19 18:25:43 +05:30
elif exists:
2026-06-28 18:53:43 +05:30
# Single file: key stays path.as_posix(), but the on-disk
# path is just the path itself (no join — see above).
entries.append((path.as_posix(), pathstr))
def _get_file_info(
entry: tuple[str, str],
) -> tuple[str, DirectoryManifestFile]:
key, fullfilepath = entry
2022-06-30 00:31:52 +05:30
sha = hashlib.sha256()
if not os.path.isfile(fullfilepath):
2023-08-13 17:21:49 +05:30
raise RuntimeError(f'File not found: "{fullfilepath}".')
2026-06-28 18:53:43 +05:30
# Stream the file through sha256 to keep peak memory
# bounded — manifest generation must not load arbitrarily
# large files into RAM, since the whole point of streaming
# uploads is to handle files larger than process memory.
filesize = 0
2022-06-30 00:31:52 +05:30
with open(fullfilepath, 'rb') as infile:
2026-06-28 18:53:43 +05:30
for chunk in iter(lambda: infile.read(1024 * 1024), b''):
sha.update(chunk)
filesize += len(chunk)
return (
2026-06-28 18:53:43 +05:30
key,
DirectoryManifestFile(
2026-06-28 18:53:43 +05:30
hash_sha256=sha.hexdigest(),
size=filesize,
),
)
2022-06-30 00:31:52 +05:30
# Now use all procs to hash the files efficiently.
cpus = os.cpu_count()
if cpus is None:
cpus = 4
with ThreadPoolExecutor(max_workers=cpus) as executor:
2024-05-19 18:25:43 +05:30
return cls(
2026-06-28 18:53:43 +05:30
files=dict(executor.map(_get_file_info, entries)),
exists=exists,
2024-05-19 18:25:43 +05:30
)
2022-06-30 00:31:52 +05:30
2022-07-16 17:59:14 +05:30
def validate(self) -> None:
"""Log any odd data in the manifest; for debugging."""
import logging
2022-07-16 17:59:14 +05:30
for fpath, _fentry in self.files.items():
# We want to be dealing in only forward slashes; make sure
# that's the case (wondering if we'll ever see backslashes
# for escape purposes).
if '\\' in fpath:
logging.exception(
"Found unusual path in manifest: '%s'.", fpath
)
2022-07-16 17:59:14 +05:30
break # 1 error is enough for now.
2024-01-27 21:25:16 +05:30
# @classmethod
# def get_empty_hash(cls) -> str:
# """Return the hash for an empty file."""
# if cls._empty_hash is None:
# import hashlib
2024-01-27 21:25:16 +05:30
# sha = hashlib.sha256()
# cls._empty_hash = sha.hexdigest()
# return cls._empty_hash