1616from datetime import datetime
1717from io import BytesIO
1818from 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
2020from zipfile import BadZipFile , ZipFile
2121from zlib import crc32
2222
4545
4646
4747if 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+
180186class 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 )
595687class 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 )
651726class 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 )
709778class 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