Skip to content

Commit 4733287

Browse files
committed
Implement changes for python/cpython#103975
1 parent 2292ca9 commit 4733287

File tree

1 file changed

+175
-94
lines changed

1 file changed

+175
-94
lines changed

steam/manifest.py

Lines changed: 175 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from datetime import datetime
1717
from io import BytesIO
1818
from operator import attrgetter, methodcaller
19-
from typing import TYPE_CHECKING, Any, Final, Literal, TypeGuard, cast
19+
from typing import TYPE_CHECKING, Any, Final, Literal, TypeGuard, cast, overload
2020
from zipfile import BadZipFile, ZipFile
2121
from zlib import crc32
2222

@@ -45,6 +45,8 @@
4545

4646

4747
if TYPE_CHECKING:
48+
from _typeshed import StrPath
49+
4850
from .state import ConnectionState
4951
from .types import manifest
5052
from .types.vdf import VDFInt
@@ -177,6 +179,10 @@ def read_nowait(self, n: int = -1, /) -> bytes:
177179
return content
178180

179181

182+
def _manifest_parts(filename: str) -> list[str]:
183+
return filename.rstrip("\x00 \n\t").split("\\")
184+
185+
180186
class ManifestPath(PurePathBase, _IOMixin):
181187
"""A :class:`pathlib.PurePath` subclass representing a binary file in a Manifest. This class is broadly compatible
182188
with :class:`pathlib.Path`.
@@ -201,12 +207,68 @@ class ManifestPath(PurePathBase, _IOMixin):
201207
_manifest: Manifest
202208
_mapping: PayloadFileMapping
203209

204-
def __new__(cls, manifest: Manifest, mapping: PayloadFileMapping) -> Self:
205-
# super().__new__ breaks
206-
self: Self = super()._from_parts(mapping.filename.rstrip("\x00 \n\t").split("\\")) # type: ignore
207-
self._mapping = mapping
208-
self._manifest = manifest
209-
return self
210+
if sys.version_info < (3, 12):
211+
212+
def __new__(cls, *args: StrPath, manifest: Manifest, mapping: PayloadFileMapping | None = None) -> Self:
213+
# super().__new__ breaks
214+
self: Self = super()._from_parts(_manifest_parts(mapping.filename) if mapping is not None else args) # type: ignore
215+
self._manifest = manifest
216+
if mapping is not None:
217+
self._mapping = mapping
218+
return self
219+
220+
def with_segments(self, *args: StrPath) -> Self:
221+
return self._select_from_manifest(self._from_parts(self.parts + tuple(map(os.fspath, args))))
222+
223+
def _select_from_manifest(self, new_self: Self) -> Self:
224+
try:
225+
# try and return the actual path if exists
226+
return self._manifest._paths[new_self.parts]
227+
except KeyError:
228+
# else attach the manifest and return, this will not support most operations
229+
new_self._manifest = self._manifest
230+
return new_self
231+
232+
def _from_parts(self, args: tuple[str, ...]) -> Self:
233+
new_self = super()._from_parts(args) # type: ignore
234+
return self._select_from_manifest(new_self)
235+
236+
def _from_parsed_parts(self, drv: str, root: str, parts: tuple[str, ...]) -> Self:
237+
new_self = super()._from_parsed_parts(drv, root, parts) # type: ignore
238+
return self._select_from_manifest(new_self)
239+
240+
@property
241+
def parents(self) -> tuple[Self, ...]:
242+
"""A tuple of this path's logical parents."""
243+
path = self
244+
parent = self.parent
245+
parents: list[Self] = []
246+
while path != parent:
247+
parents.append(parent)
248+
path, parent = parent, parent.parent
249+
return tuple(parents)
250+
251+
else:
252+
253+
def __init__(
254+
self,
255+
*args: StrPath,
256+
manifest: Manifest,
257+
mapping: PayloadFileMapping | None = None,
258+
):
259+
super().__init__(*_manifest_parts(mapping.filename) if mapping is not None else args)
260+
self._manifest = manifest
261+
if mapping is not None:
262+
self._mapping = mapping
263+
264+
def with_segments(self, *args: StrPath) -> Self:
265+
new_self = self.__class__(*args, manifest=self._manifest)
266+
try:
267+
# try and return the actual path if exists
268+
return self._manifest._paths[new_self.parts]
269+
except KeyError:
270+
# else attach the manifest and return, this will not support most operations
271+
return new_self
210272

211273
def __repr__(self) -> str:
212274
return f"<{self.__class__.__name__} {str(self)!r}>"
@@ -215,36 +277,8 @@ def __repr__(self) -> str:
215277

216278
def __getattr__(self, name: str) -> Never:
217279
if name in self.__annotations__: # give a more helpful error
218-
raise AttributeError("Attempting operations on a non-existent file")
219-
raise AttributeError(f"{self.__class__.__name__!r} object has no attribute {name!r}")
220-
221-
def _select_from_manifest(self, new_self: Self) -> Self:
222-
try:
223-
# try and return the actual path if exists
224-
return self._manifest._paths[new_self.parts]
225-
except KeyError:
226-
# else attach the manifest and return, this will not support most operations
227-
new_self._manifest = self._manifest
228-
return new_self
229-
230-
def _from_parts(self, args: tuple[str, ...]) -> Self:
231-
new_self = super()._from_parts(args) # type: ignore
232-
return self._select_from_manifest(new_self)
233-
234-
def _from_parsed_parts(self, drv: str, root: str, parts: tuple[str, ...]) -> Self:
235-
new_self = super()._from_parsed_parts(drv, root, parts) # type: ignore
236-
return self._select_from_manifest(new_self)
237-
238-
@property
239-
def parents(self) -> tuple[Self, ...]:
240-
"""A tuple of this path's logical parents."""
241-
path = self
242-
parent = self.parent
243-
parents: list[Self] = []
244-
while path != parent:
245-
parents.append(parent)
246-
path, parent = parent, parent.parent
247-
return tuple(parents)
280+
raise ValueError("Attempting operations on a non-existent file")
281+
raise AttributeError(f"{self.__class__.__name__!r} object has no attribute {name!r}", name=name, obj=self)
248282

249283
@property
250284
def size(self) -> int:
@@ -302,9 +336,66 @@ def readlink(self) -> Self:
302336
if not self.is_symlink():
303337
raise OSError(errno.EINVAL, os.strerror(errno.EINVAL), str(self))
304338

305-
link_parts = tuple(self._mapping.linktarget.rstrip("\x00 \n\t").split("\\"))
339+
link_parts = tuple(_manifest_parts(self._mapping.linktarget))
306340
return self._manifest._paths[link_parts]
307341

342+
@overload
343+
def resolve(self, *, strict: bool = False) -> Self: # type: ignore
344+
...
345+
346+
def resolve(self, *, strict: bool = False, _follow_symlinks: bool = True) -> Self:
347+
"""Return the canonical path of the symbolic link, eliminating any symbolic links encountered in the path.
348+
Similar to :meth:`pathlib.Path.resolve`
349+
350+
Parameters
351+
----------
352+
strict
353+
Whether to raise an error if a path doesn't exist.
354+
355+
Raises
356+
------
357+
FileNotFoundError
358+
If ``strict`` is ``True`` and the path doesn't exist.
359+
RuntimeError
360+
If a recursive path is detected.
361+
"""
362+
new_parts: list[str] = []
363+
seen = set[tuple[str, ...]]()
364+
idx = 0
365+
raw_parts = list(self.parts)
366+
if not raw_parts:
367+
raise RuntimeError("Cannot resolve empty path")
368+
369+
for part in raw_parts:
370+
match part:
371+
case "." | "":
372+
idx += 1
373+
continue
374+
case "..":
375+
raw_parts.insert(idx + 1, raw_parts[idx - 1])
376+
idx += 1
377+
continue
378+
new_parts.append(part)
379+
380+
path = self.with_segments(*new_parts)
381+
382+
if not hasattr(path, "_mapping"):
383+
if strict:
384+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(self))
385+
elif path.is_symlink() and _follow_symlinks:
386+
new_parts = _manifest_parts(path._mapping.linktarget)
387+
388+
if (tuple_parts := tuple(new_parts)) in seen:
389+
raise RuntimeError("Recursive path detected. Cannot resolve")
390+
seen.add(tuple_parts)
391+
idx += 1
392+
393+
return path # type: ignore # cannot be unbound
394+
395+
def exists(self, *, follow_symlinks: bool = True) -> bool:
396+
"""Return whether this path exists. Similar to :meth:`pathlib.Path.exists`."""
397+
return hasattr(self.resolve(strict=False, _follow_symlinks=follow_symlinks), "_mapping")
398+
308399
def iterdir(self) -> Generator[Self, None, None]:
309400
"""Iterate over this path. Similar to :meth:`pathlib.Path.iterdir`."""
310401
for path in self._manifest._paths.values():
@@ -508,8 +599,8 @@ def __len__(self) -> int:
508599

509600
@cached_slot_property("_cs_paths")
510601
def _paths(self) -> dict[tuple[str, ...], ManifestPath]:
511-
return {("/",): ManifestPath(self, PayloadFileMapping("/", flags=DepotFileFlag.Directory))} | {
512-
(path := ManifestPath(self, mapping)).parts: path for mapping in self._payload.mappings
602+
return {("/",): ManifestPath(manifest=self, mapping=PayloadFileMapping("/", flags=DepotFileFlag.Directory))} | {
603+
(path := ManifestPath(manifest=self, mapping=mapping)).parts: path for mapping in self._payload.mappings
513604
}
514605

515606
@property
@@ -592,48 +683,31 @@ async def fetch_manifest(
592683
return manifest
593684

594685

686+
@dataclass(slots=True)
595687
class Branch:
596688
"""Represents a branch on for a Steam app. Branches are specific builds of an application that have made available
597689
publicly or privately through Steam.
598690
599691
Read more on `steamworks <https://partner.steamgames.com/doc/store/application/branches>`_.
600692
"""
601693

602-
__slots__ = (
603-
"name",
604-
"build_id",
605-
"password_required",
606-
"updated_at",
607-
"description",
608-
"depots",
609-
"password",
610-
)
611-
612-
def __init__(
613-
self,
614-
name: str,
615-
build_id: int,
616-
updated_at: datetime | None,
617-
password_required: bool,
618-
description: str | None,
619-
):
620-
self.name = name
621-
"""The name of the branch."""
622-
self.build_id = build_id
623-
"""
624-
The branch's build ID. This is a globally incrementing number. Build IDs are updated when a new build of an
625-
application is pushed.
626-
"""
627-
self.password_required = password_required
628-
"""Whether a password is required to access this branch."""
629-
self.updated_at = updated_at
630-
"""The time this branch was last updated."""
631-
self.description = description
632-
"""This branch's description."""
633-
self.depots: list[Depot] = []
634-
"""This branch's depots."""
635-
self.password: str | None = None
636-
"""This branch's password."""
694+
name: str
695+
"""The name of the branch."""
696+
build_id: int
697+
"""
698+
The branch's build ID. This is a globally incrementing number. Build IDs are updated when a new build of an
699+
application is pushed.
700+
"""
701+
updated_at: datetime | None
702+
"""The time this branch was last updated."""
703+
password_required: bool
704+
"""Whether a password is required to access this branch."""
705+
description: str | None
706+
"""This branch's description."""
707+
depots: list[Depot] = field(default_factory=list)
708+
"""This branch's depots."""
709+
password: str | None = None
710+
"""This branch's password."""
637711

638712
def __repr__(self) -> str:
639713
return f"<{self.__class__.__name__} name={self.name!r} build_id={self.build_id}>"
@@ -648,25 +722,20 @@ async def fetch_manifests(self) -> list[Manifest]:
648722
return await asyncio.gather(*(manifest.fetch() for manifest in self.manifests)) # type: ignore # typeshed lies
649723

650724

725+
@dataclass(slots=True)
651726
class ManifestInfo:
652727
"""Represents information about a manifest."""
653728

654-
__slots__ = ("_state", "id", "branch", "depot")
655-
depot: Depot
729+
_state: ConnectionState
730+
id: ManifestID
731+
"""The manifest's ID."""
732+
branch: Branch
733+
"""The branch this manifest is for."""
734+
size: int | None
735+
download_size: int | None
736+
depot: Depot = field(init=False)
656737
"""The depot this manifest is for."""
657738

658-
def __init__(
659-
self,
660-
state: ConnectionState,
661-
id: ManifestID,
662-
branch: Branch,
663-
):
664-
self._state = state
665-
self.id = id
666-
"""The manifest's ID."""
667-
self.branch = branch
668-
"""The branch this manifest is for."""
669-
670739
def __repr__(self) -> str:
671740
return f"<{self.__class__.__name__} name={self.name!r} id={self.id}>"
672741

@@ -688,14 +757,14 @@ def __init__(self, state: ConnectionState, encrypted_id: str, branch: Branch):
688757
self.encrypted_id = encrypted_id
689758
self.branch = branch
690759

691-
@cached_slot_property
692-
def id(self) -> int:
760+
@cached_slot_property # type: ignore
761+
def id(self) -> ManifestID:
693762
if self.branch.password is None:
694763
raise ValueError("Cannot access the id of this depot as the password is not set.")
695764
cipher = Cipher(algorithms.AES(self.branch.password.encode("UTF-8")), modes.ECB())
696765
decryptor = cipher.decryptor()
697766
to_unpack = utils.unpad(decryptor.update(bytes.fromhex(self.encrypted_id) + decryptor.finalize()))
698-
return struct.unpack("<Q", to_unpack)[0]
767+
return ManifestID(*struct.unpack("<Q", to_unpack))
699768

700769
@staticmethod
701770
def _get_id(depots: manifest.Depot, branch: Branch) -> VDFInt | None:
@@ -705,7 +774,7 @@ def _get_id(depots: manifest.Depot, branch: Branch) -> VDFInt | None:
705774
return None
706775

707776

708-
@dataclass(repr=False, slots=True)
777+
@dataclass(slots=True)
709778
class HeadlessDepot:
710779
"""Represents a depot without a branch."""
711780

@@ -1068,7 +1137,19 @@ def __repr__(self) -> str:
10681137
resolved = [f"{name}={getattr(self, name)!r}" for name in attrs]
10691138
return f"<{self.__class__.__name__} {' '.join(resolved)}>"
10701139

1071-
async def apps(self, *, language: Language | None = None) -> list[PartialApp[None]]:
1140+
@overload
1141+
async def apps(self, *, language: Language) -> list[PartialApp[str]]:
1142+
...
1143+
1144+
@overload
1145+
async def apps(self, *, language: None = ...) -> list[PartialApp[None]]:
1146+
...
1147+
1148+
async def apps( # type: ignore[reportIncompatibleMethodOverride]
1149+
self,
1150+
*,
1151+
language: Language | None = None,
1152+
) -> list[PartialApp[None]] | list[PartialApp[str]]:
10721153
if language is not None:
10731154
return await super().apps(language=language)
10741155
return self._apps

0 commit comments

Comments
 (0)