Skip to content

Commit 3e7d9b5

Browse files
committed
feat: fetch external objects using asset index
1 parent 6b67d4e commit 3e7d9b5

File tree

5 files changed

+236
-33
lines changed

5 files changed

+236
-33
lines changed

beet/contrib/vanilla.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
"ReleaseRegistry",
88
"Release",
99
"ClientJar",
10+
"AssetIndex",
1011
"MANIFEST_URL",
12+
"RESOURCES_URL",
1113
]
1214

1315

1416
from pathlib import Path
15-
from typing import Optional, Union
17+
from typing import Iterator, Optional, Union
1618
from zipfile import ZipFile
1719

1820
from pydantic import BaseModel
@@ -25,10 +27,12 @@
2527
DataPack,
2628
JsonFile,
2729
ResourcePack,
30+
UnveilMapping,
2831
)
2932
from beet.core.utils import FileSystemPath, log_time
3033

3134
MANIFEST_URL: str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
35+
RESOURCES_URL: str = "https://resources.download.minecraft.net"
3236

3337

3438
class VanillaOptions(BaseModel):
@@ -50,11 +54,15 @@ def __init__(self, cache: Cache, path: FileSystemPath):
5054
self.assets = ResourcePack()
5155
self.data = DataPack()
5256

53-
def mount(self, prefix: Optional[str] = None) -> "ClientJar":
57+
def mount(
58+
self,
59+
prefix: Optional[str] = None,
60+
object_mapping: Optional[UnveilMapping] = None,
61+
) -> "ClientJar":
5462
"""Mount the specified prefix if it's not available already."""
5563
if not prefix:
56-
self.mount("assets")
57-
self.mount("data")
64+
self.mount("assets", object_mapping)
65+
self.mount("data", object_mapping)
5866
return self
5967

6068
if prefix.startswith("assets"):
@@ -70,28 +78,69 @@ def mount(self, prefix: Optional[str] = None) -> "ClientJar":
7078
with log_time("Extract vanilla pack."):
7179
pack.load(ZipFile(self.path))
7280
pack.save(path=path)
73-
return self
74-
75-
if pack.path == path.parent:
76-
return self
81+
elif pack.path != path.parent:
82+
pack.unveil(prefix, path)
7783

78-
pack.unveil(prefix, path)
84+
if object_mapping and isinstance(pack, ResourcePack):
85+
with self.cache.parallel_downloads():
86+
pack.unveil(prefix, object_mapping)
7987

8088
return self
8189

8290

91+
class AssetIndex(Container[str, FileSystemPath]):
92+
"""Class for retrieving assets referenced by a particular release."""
93+
94+
cache: Cache
95+
info: JsonFile
96+
97+
def __init__(self, cache: Cache, info: JsonFile):
98+
super().__init__()
99+
self.cache = cache
100+
self.info = info
101+
102+
def missing(self, key: str) -> FileSystemPath:
103+
if not key.startswith("assets/"):
104+
raise KeyError(key)
105+
106+
try:
107+
object_hash: str = self.info.data["objects"][key[7:]]["hash"]
108+
except KeyError as exc:
109+
raise KeyError(key) from exc
110+
111+
path = self.cache.directory / "objects" / object_hash[:2] / object_hash
112+
113+
if not path.is_file():
114+
path.parent.mkdir(parents=True, exist_ok=True)
115+
self.cache.download(
116+
f"{RESOURCES_URL}/{object_hash[:2]}/{object_hash}",
117+
path,
118+
)
119+
120+
return path
121+
122+
def __iter__(self) -> Iterator[str]:
123+
for key in self.info.data["objects"]:
124+
yield f"assets/{key}"
125+
126+
def __len__(self) -> int:
127+
return len(self.info.data["objects"])
128+
129+
83130
class Release:
84131
"""Class holding information about a minecraft release."""
85132

86133
cache: Cache
87134
info: JsonFile
88135

89136
_client_jar: Optional[ClientJar]
137+
_object_mapping: Optional[UnveilMapping]
90138

91139
def __init__(self, cache: Cache, info: JsonFile):
92140
self.cache = cache
93141
self.info = info
94142
self._client_jar = None
143+
self._object_mapping = None
95144

96145
@property
97146
def type(self) -> str:
@@ -104,8 +153,24 @@ def client_jar(self) -> ClientJar:
104153
self._client_jar = ClientJar(self.cache, path)
105154
return self._client_jar
106155

107-
def mount(self, prefix: Optional[str] = None) -> ClientJar:
108-
return self.client_jar.mount(prefix)
156+
@property
157+
def object_mapping(self) -> UnveilMapping:
158+
if not self._object_mapping:
159+
path = self.cache.download(self.info.data["assetIndex"]["url"])
160+
self._object_mapping = UnveilMapping(
161+
AssetIndex(self.cache, JsonFile(source_path=path))
162+
)
163+
return self._object_mapping
164+
165+
def mount(
166+
self,
167+
prefix: Optional[str] = None,
168+
fetch_objects: bool = False,
169+
) -> ClientJar:
170+
return self.client_jar.mount(
171+
prefix=prefix,
172+
object_mapping=self.object_mapping if fetch_objects else None,
173+
)
109174

110175
@property
111176
def assets(self) -> ResourcePack:
@@ -182,8 +247,12 @@ def __init__(
182247
else:
183248
self.minecraft_version = LATEST_MINECRAFT_VERSION
184249

185-
def mount(self, prefix: Optional[str] = None) -> ClientJar:
186-
return self.releases[self.minecraft_version].mount(prefix)
250+
def mount(
251+
self,
252+
prefix: Optional[str] = None,
253+
fetch_objects: bool = False,
254+
) -> ClientJar:
255+
return self.releases[self.minecraft_version].mount(prefix, fetch_objects)
187256

188257
@property
189258
def assets(self) -> ResourcePack:

beet/core/cache.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@
33
"Cache",
44
"CachePin",
55
"CacheTransaction",
6+
"DownloadManager",
67
]
78

89

910
import json
1011
import logging
1112
import shutil
12-
from contextlib import contextmanager
13+
from concurrent.futures import Executor, ThreadPoolExecutor
14+
from contextlib import closing, contextmanager
1315
from datetime import datetime, timedelta
1416
from pathlib import Path
1517
from textwrap import indent
16-
from typing import Any, ClassVar, Iterator, Optional, Set, Type, TypeVar
18+
from typing import Any, BinaryIO, ClassVar, Iterator, Optional, Set, Type, TypeVar
1719
from urllib.request import urlopen
1820

1921
from .container import Container, MatchMixin, Pin
@@ -58,6 +60,7 @@ class Cache:
5860
index_path: Path
5961
index: JsonDict
6062
transaction: CacheTransaction
63+
download_manager: "DownloadManager"
6164

6265
index_file: ClassVar[str] = "index.json"
6366

@@ -75,6 +78,7 @@ def __init__(
7578
else self.get_initial_index()
7679
)
7780
self.transaction = transaction or CacheTransaction()
81+
self.download_manager = DownloadManager()
7882
self.flush()
7983

8084
def get_initial_index(self) -> JsonDict:
@@ -112,15 +116,22 @@ def get_path(self, key: str) -> Path:
112116

113117
return self.directory / path
114118

115-
def download(self, url: str) -> Path:
119+
@contextmanager
120+
def parallel_downloads(self, max_workers: Optional[int] = None):
121+
"""Launch multiple requests at the same time."""
122+
with DownloadManager.parallel(max_workers) as parallel_download_manager:
123+
previous_manager = self.download_manager
124+
self.download_manager = parallel_download_manager
125+
try:
126+
yield
127+
finally:
128+
self.download_manager = previous_manager
129+
130+
def download(self, url: str, path: Optional[FileSystemPath] = None) -> Path:
116131
"""Download and cache a given url."""
117-
path = self.get_path(url)
118-
119-
if not path.is_file():
120-
with log_time('Download "%s".', url), urlopen(url) as f:
121-
path.write_bytes(f.read())
122-
123-
return path
132+
if not path:
133+
path = self.get_path(url)
134+
return self.download_manager.download(url, path)
124135

125136
def has_changed(self, *filenames: Optional[FileSystemPath]) -> bool:
126137
"""Return whether any of the given files changed since the last check."""
@@ -358,3 +369,37 @@ def flush(self):
358369

359370
def __repr__(self) -> str:
360371
return f"{self.__class__.__name__}({str(self.path)!r})"
372+
373+
374+
class DownloadManager:
375+
"""Download manager."""
376+
377+
executor: Optional[Executor]
378+
379+
def __init__(self, executor: Optional[Executor] = None):
380+
self.executor = executor
381+
382+
@classmethod
383+
@contextmanager
384+
def parallel(cls, max_workers: Optional[int] = None):
385+
"""Create a download manager that launches multiple requests at the same time."""
386+
with ThreadPoolExecutor(max_workers) as executor:
387+
yield cls(executor)
388+
389+
def download(self, url: str, path: FileSystemPath) -> Path:
390+
"""Download and cache a given url."""
391+
path = Path(path)
392+
393+
if not path.is_file():
394+
fileobj = path.open("wb")
395+
if self.executor:
396+
self.executor.submit(self.retrieve, url, fileobj)
397+
else:
398+
self.retrieve(url, fileobj)
399+
400+
return path
401+
402+
def retrieve(self, url: str, fileobj: BinaryIO):
403+
"""Retrieve file from url."""
404+
with log_time('Download "%s".', url), closing(fileobj), urlopen(url) as f:
405+
fileobj.write(f.read())

beet/core/file.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,17 @@
2727
from copy import deepcopy
2828
from dataclasses import dataclass, replace
2929
from pathlib import Path
30-
from typing import Any, Callable, ClassVar, Generic, Optional, Type, TypeVar, Union
30+
from typing import (
31+
Any,
32+
Callable,
33+
ClassVar,
34+
Generic,
35+
Mapping,
36+
Optional,
37+
Type,
38+
TypeVar,
39+
Union,
40+
)
3141
from zipfile import ZipFile
3242

3343
import yaml
@@ -62,7 +72,7 @@ def open_image(*args: Any, **kwargs: Any) -> Any:
6272
SerializeType = TypeVar("SerializeType", bound=Any)
6373
FileType = TypeVar("FileType", bound="File[Any, Any]")
6474

65-
FileOrigin = Union[FileSystemPath, ZipFile]
75+
FileOrigin = Union[FileSystemPath, ZipFile, Mapping[str, FileSystemPath]]
6676
TextFileContent = Union[ValueType, str, None]
6777
BinaryFileContent = Union[ValueType, bytes, None]
6878

@@ -242,6 +252,12 @@ def try_load(
242252
return cls(cls.from_zip(origin, str(path)))
243253
except KeyError:
244254
return None
255+
elif isinstance(origin, Mapping):
256+
try:
257+
path = "" if path == Path() else str(path)
258+
origin, path = origin[path], ""
259+
except KeyError:
260+
return None
245261
path = Path(origin, path)
246262
return cls(source_path=path) if path.is_file() else None
247263

@@ -255,6 +271,8 @@ def dump_zip(self, origin: ZipFile, name: str, raw: SerializeType) -> None:
255271

256272
def dump(self, origin: FileOrigin, path: FileSystemPath):
257273
"""Write the file to a zipfile or to the filesystem."""
274+
if isinstance(origin, Mapping):
275+
raise TypeError(f'Can\'t dump file "{path}" to read-only mapping.')
258276
if self._content is None:
259277
if isinstance(origin, ZipFile):
260278
origin.write(self.ensure_source_path(), str(path))

0 commit comments

Comments
 (0)