|
|
@ -13,20 +13,28 @@ from typing import Tuple, Dict, List, Union, Set, Callable, Any |
|
|
|
from steamutil import Steam, App, CachedProperty, MalformedManifestError |
|
|
|
from steamutil import Steam, App, CachedProperty, MalformedManifestError |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AppNotFoundType: |
|
|
|
### ----------------------------------------------------------------- |
|
|
|
def __call__(self, func) -> None: |
|
|
|
# Sync Abstractions |
|
|
|
pass |
|
|
|
### ----------------------------------------------------------------- |
|
|
|
|
|
|
|
|
|
|
|
def __bool__(self) -> bool: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AppNotFound = AppNotFoundType() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncPath: |
|
|
|
class SyncPath: |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
A SyncPath represents a pair of paths: |
|
|
|
|
|
|
|
local/common ; target/common |
|
|
|
|
|
|
|
Whereby target is the location being synched to and local is |
|
|
|
|
|
|
|
the prefix the data is synched from on the local machine. |
|
|
|
|
|
|
|
Common has components common to both paths. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Usually, you'd set the local prefix and then the common part. |
|
|
|
|
|
|
|
e.g.: op.home.prefix(".my_game") / "Savegames" |
|
|
|
|
|
|
|
whereby "Savegames" is included in the resulting target |
|
|
|
|
|
|
|
path, but ".my_game" is not. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Note that SyncPath should be considered immutable. Relevant |
|
|
|
|
|
|
|
methods return a new instance. |
|
|
|
|
|
|
|
""" |
|
|
|
__slots__ = "op", "local", "common" |
|
|
|
__slots__ = "op", "local", "common" |
|
|
|
|
|
|
|
|
|
|
|
op: 'SyncOp' |
|
|
|
op: 'AbstractSyncOp' |
|
|
|
local: Path |
|
|
|
local: Path |
|
|
|
common: Path |
|
|
|
common: Path |
|
|
|
|
|
|
|
|
|
|
@ -34,21 +42,32 @@ class SyncPath: |
|
|
|
self.op = op |
|
|
|
self.op = op |
|
|
|
self.local = Path(local) |
|
|
|
self.local = Path(local) |
|
|
|
self.common = Path(common) |
|
|
|
self.common = Path(common) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Change paths |
|
|
|
def prefix(self, component: Union[str, Path]) -> 'SyncPath': |
|
|
|
def prefix(self, component: Union[str, Path]) -> 'SyncPath': |
|
|
|
|
|
|
|
""" Return a new SyncPath that has a component prefixed to the local path """ |
|
|
|
return SyncPath(self.op, self.local / component, self.common) |
|
|
|
return SyncPath(self.op, self.local / component, self.common) |
|
|
|
|
|
|
|
|
|
|
|
def __div__(self, component: Union[str, Path]) -> 'SyncPath': |
|
|
|
def __div__(self, component: Union[str, Path]) -> 'SyncPath': |
|
|
|
|
|
|
|
""" Return a new SyncPath that nas a component added """ |
|
|
|
return SyncPath(self.op, self.local, self.common / component) |
|
|
|
return SyncPath(self.op, self.local, self.common / component) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Retrieve paths |
|
|
|
@property |
|
|
|
@property |
|
|
|
def path(self) -> Path: |
|
|
|
def path(self) -> Path: |
|
|
|
|
|
|
|
""" Get the local path """ |
|
|
|
return self.local / self.common |
|
|
|
return self.local / self.common |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def exists(self) -> bool: |
|
|
|
|
|
|
|
""" Chech whether local path exists """ |
|
|
|
|
|
|
|
return self.path.exists() |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
@property |
|
|
|
def target_path(self) -> Path: |
|
|
|
def target_path(self) -> Path: |
|
|
|
|
|
|
|
""" Get the sync target path """ |
|
|
|
return self.op.target_path / self.common |
|
|
|
return self.op.target_path / self.common |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Begin a SyncSet |
|
|
|
def __enter__(self) -> 'SyncSet': |
|
|
|
def __enter__(self) -> 'SyncSet': |
|
|
|
return SyncSet(self) |
|
|
|
return SyncSet(self) |
|
|
|
|
|
|
|
|
|
|
@ -58,9 +77,13 @@ class SyncPath: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncSet: |
|
|
|
class SyncSet: |
|
|
|
|
|
|
|
""" |
|
|
|
|
|
|
|
A SyncSet represents a set of files to be synchronized |
|
|
|
|
|
|
|
from a local to a target location represented by a SyncPath |
|
|
|
|
|
|
|
""" |
|
|
|
FileStatSet = Dict[Path, Tuple[Path, os.stat_result]] |
|
|
|
FileStatSet = Dict[Path, Tuple[Path, os.stat_result]] |
|
|
|
|
|
|
|
|
|
|
|
op: 'SyncOp' |
|
|
|
op: 'AbstractSyncOp' |
|
|
|
spath: SyncPath |
|
|
|
spath: SyncPath |
|
|
|
local: FileStatSet |
|
|
|
local: FileStatSet |
|
|
|
target: FileStatSet |
|
|
|
target: FileStatSet |
|
|
@ -74,10 +97,12 @@ class SyncSet: |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
@property |
|
|
|
def path(self) -> Path: |
|
|
|
def path(self) -> Path: |
|
|
|
|
|
|
|
""" The local path """ |
|
|
|
return self.spath.path |
|
|
|
return self.spath.path |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
@property |
|
|
|
def target_path(self) -> Path: |
|
|
|
def target_path(self) -> Path: |
|
|
|
|
|
|
|
""" The target path """ |
|
|
|
return self.spath.target_path |
|
|
|
return self.spath.target_path |
|
|
|
|
|
|
|
|
|
|
|
# Modify inclusion |
|
|
|
# Modify inclusion |
|
|
@ -131,12 +156,16 @@ class SyncSet: |
|
|
|
def files_unmodified(self) -> Set[Path]: |
|
|
|
def files_unmodified(self) -> Set[Path]: |
|
|
|
return (self.local.keys() | self.target.keys()) - (self.files_from_local | self.files_from_target) |
|
|
|
return (self.local.keys() | self.target.keys()) - (self.files_from_local | self.files_from_target) |
|
|
|
|
|
|
|
|
|
|
|
def show_confirm(self) -> bool: |
|
|
|
def show_confirm(self, skip=True) -> bool: |
|
|
|
# XXX: move to SyncOp? |
|
|
|
# XXX: move to SyncOp? |
|
|
|
print(" Local is newer: ", ", ".join(map(str, self.files_from_local))) |
|
|
|
print(" Local is newer: ", ", ".join(map(str, self.files_from_local))) |
|
|
|
print(" Target is newer: ", ", ".join(map(str, self.files_from_target))) |
|
|
|
print(" Target is newer: ", ", ".join(map(str, self.files_from_target))) |
|
|
|
print(" Unmodified: ", ", ".join(map(str, self.files_unmodified))) |
|
|
|
print(" Unmodified: ", ", ".join(map(str, self.files_unmodified))) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if skip and not self.files_from_local and not self.files_from_target: |
|
|
|
|
|
|
|
print("Noting to do!") |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
print("Continue? <Y/n> ", end="") |
|
|
|
print("Continue? <Y/n> ", end="") |
|
|
|
resp = input().strip() |
|
|
|
resp = input().strip() |
|
|
|
if resp.lower() in ("y", "yes", ""): |
|
|
|
if resp.lower() in ("y", "yes", ""): |
|
|
@ -157,20 +186,43 @@ class SyncSet: |
|
|
|
return self.op._do_copy(operations) |
|
|
|
return self.op._do_copy(operations) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncOp: |
|
|
|
### ----------------------------------------------------------------- |
|
|
|
|
|
|
|
# Sync Operation |
|
|
|
|
|
|
|
### ----------------------------------------------------------------- |
|
|
|
|
|
|
|
class AbstractSyncOp: |
|
|
|
parent: 'SteamSync' |
|
|
|
parent: 'SteamSync' |
|
|
|
app: App |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, ssync, app): |
|
|
|
def __init__(self, parent: 'SteamSync'): |
|
|
|
self.parent = ssync |
|
|
|
self.parent = parent |
|
|
|
self.app = app |
|
|
|
|
|
|
|
|
|
|
|
# Properties |
|
|
|
def __call__(self, func: Callable[['SyncOp'],Any]): |
|
|
|
@property |
|
|
|
|
|
|
|
def name(self): |
|
|
|
|
|
|
|
""" Name of the app """ |
|
|
|
|
|
|
|
raise NotImplementedError() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
|
|
|
|
def slug(self): |
|
|
|
|
|
|
|
""" Name of the destination folder """ |
|
|
|
|
|
|
|
return self.name |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@slug.setter |
|
|
|
|
|
|
|
def slug(self, value: str): |
|
|
|
|
|
|
|
dict(self)["slug"] = value |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
|
|
|
|
def target_path(self) -> Path: |
|
|
|
|
|
|
|
""" Full path to copy saves to """ |
|
|
|
|
|
|
|
return self.parent.target_path / self.slug |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __call__(self, func: Callable[['AbstractSyncOp'], Any]): |
|
|
|
# For decorator use |
|
|
|
# For decorator use |
|
|
|
self._report_begin() |
|
|
|
self._report_begin() |
|
|
|
return func(self) |
|
|
|
return func(self) |
|
|
|
|
|
|
|
|
|
|
|
def _do_copy(self, ops: List[Tuple[Path, Path]]) -> bool: |
|
|
|
# Actual Copy Logic |
|
|
|
|
|
|
|
@staticmethod |
|
|
|
|
|
|
|
def _do_copy(ops: List[Tuple[Path, Path]]) -> bool: |
|
|
|
for src, dst in ops: |
|
|
|
for src, dst in ops: |
|
|
|
if not dst.parent.exists(): |
|
|
|
if not dst.parent.exists(): |
|
|
|
dst.parent.mkdir(parents=True) |
|
|
|
dst.parent.mkdir(parents=True) |
|
|
@ -178,25 +230,24 @@ class SyncOp: |
|
|
|
print(" \033[36m%s -> %s\033[0m" % (src, dst)) |
|
|
|
print(" \033[36m%s -> %s\033[0m" % (src, dst)) |
|
|
|
shutil.copy2(src, dst) |
|
|
|
shutil.copy2(src, dst) |
|
|
|
return True |
|
|
|
return True |
|
|
|
|
|
|
|
|
|
|
|
# UI |
|
|
|
# UI |
|
|
|
def _report_begin(self): |
|
|
|
def _report_begin(self): |
|
|
|
print("\033[34mNow Synchronizing App %s\033[0m" % self.app.name) |
|
|
|
print("\033[34mNow Synchronizing App \033[36m%s\033[34m (%s)\033[0m" |
|
|
|
|
|
|
|
% (self.name, self.__class__.__name__.replace("SyncOp", ""))) |
|
|
|
|
|
|
|
|
|
|
|
def report_error(self, msg: List[str]): |
|
|
|
def report_error(self, msg: List[str]): |
|
|
|
print("\033[31m"+"\n".join(" " + l for l in msg)+"\033[0m") |
|
|
|
print("\033[31m"+"\n".join(" " + l for l in msg)+"\033[0m") |
|
|
|
|
|
|
|
|
|
|
|
@CachedProperty |
|
|
|
# Start from here |
|
|
|
def target_path(self) -> Path: |
|
|
|
@property |
|
|
|
return self.parent.target_path / self.app.install_dir |
|
|
|
def home(self): |
|
|
|
|
|
|
|
return SyncPath(self, self.parent.home_path) |
|
|
|
|
|
|
|
|
|
|
|
@CachedProperty |
|
|
|
@CachedProperty |
|
|
|
def my_documents(self) -> SyncPath: |
|
|
|
def my_documents(self) -> SyncPath: |
|
|
|
""" Get the Windows "My Documents" folder """ |
|
|
|
""" Get the Windows "My Documents" folder """ |
|
|
|
if sys.platform.startswith("linux"): |
|
|
|
if sys.platform == "win32": |
|
|
|
# TODO: what about native games? |
|
|
|
|
|
|
|
return SyncPath(self, self.app.compat_drive / "users/steamuser/My Documents") |
|
|
|
|
|
|
|
elif sys.platform == "win32": |
|
|
|
|
|
|
|
def get_my_documents(): |
|
|
|
def get_my_documents(): |
|
|
|
import ctypes.wintypes |
|
|
|
import ctypes.wintypes |
|
|
|
CSIDL_PERSONAL = 5 # My Documents |
|
|
|
CSIDL_PERSONAL = 5 # My Documents |
|
|
@ -208,36 +259,92 @@ class SyncOp: |
|
|
|
return buf.value |
|
|
|
return buf.value |
|
|
|
return SyncPath(self, get_my_documents()) |
|
|
|
return SyncPath(self, get_my_documents()) |
|
|
|
else: |
|
|
|
else: |
|
|
|
raise Exception("Platform not supported") |
|
|
|
raise RuntimeError("Platform has unknown My Documents location") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def from_(self, path: Path) -> SyncPath: |
|
|
|
|
|
|
|
return SyncPath(self, path) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GenericSyncOp(AbstractSyncOp): |
|
|
|
|
|
|
|
""" Generic Sync Operation for Non-Steam Apps """ |
|
|
|
|
|
|
|
name: str = None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, parent, name): |
|
|
|
|
|
|
|
super().__init__(parent) |
|
|
|
|
|
|
|
self.name = name |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SteamSyncOp(AbstractSyncOp): |
|
|
|
|
|
|
|
""" Sync Operation for Steam Apps """ |
|
|
|
|
|
|
|
app: App |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, ssync, app): |
|
|
|
|
|
|
|
super().__init__(ssync) |
|
|
|
|
|
|
|
self.app = app |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Implement AbstractSyncOp |
|
|
|
|
|
|
|
@property |
|
|
|
|
|
|
|
def name(self): |
|
|
|
|
|
|
|
return self.app.name |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
|
|
|
|
def slug(self): |
|
|
|
|
|
|
|
return self.app.install_dir |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Addidtional information available through Steam |
|
|
|
@property |
|
|
|
@property |
|
|
|
def game_directory(self) -> SyncPath: |
|
|
|
def game_directory(self) -> SyncPath: |
|
|
|
return SyncPath(self, self.app.install_path) |
|
|
|
return SyncPath(self, self.app.install_path) |
|
|
|
|
|
|
|
|
|
|
|
def from_(self, path: Path) -> SyncPath: |
|
|
|
|
|
|
|
return SyncPath(self, path) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@CachedProperty |
|
|
|
|
|
|
|
def my_documents(self) -> SyncPath: |
|
|
|
|
|
|
|
if sys.platform.startswith("linux"): |
|
|
|
|
|
|
|
# TODO: what about native games? |
|
|
|
|
|
|
|
return SyncPath(self, self.app.compat_drive / "users/steamuser/My Documents") |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
return super().my_documents |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncNoOp: |
|
|
|
|
|
|
|
""" No-Op Sync Operation """ |
|
|
|
|
|
|
|
def __call__(self, func) -> None: |
|
|
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __bool__(self) -> bool: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AppNotFound = SyncNoOp() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### ----------------------------------------------------------------- |
|
|
|
|
|
|
|
# Main Sync manager class |
|
|
|
|
|
|
|
### ----------------------------------------------------------------- |
|
|
|
class SteamSync: |
|
|
|
class SteamSync: |
|
|
|
target_path: Path |
|
|
|
target_path: Path |
|
|
|
steam: Steam |
|
|
|
steam: Steam |
|
|
|
|
|
|
|
home_path: Path |
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, target_path: Path, *, steam_path: Path = None): |
|
|
|
def __init__(self, target_path: Path, *, steam_path: Path = None): |
|
|
|
self.target_path = Path(target_path) |
|
|
|
self.target_path = Path(target_path) |
|
|
|
self.steam = Steam(steam_path) |
|
|
|
self.steam = Steam(steam_path) |
|
|
|
|
|
|
|
self.home_path = Path("~").expanduser() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Get Information |
|
|
|
@CachedProperty |
|
|
|
@CachedProperty |
|
|
|
def apps(self) -> List[App]: |
|
|
|
def apps(self) -> List[App]: |
|
|
|
return list(self.steam.apps) |
|
|
|
return list(self.steam.apps) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Get Sync Operation for a specific App |
|
|
|
def by_id(self, appid): |
|
|
|
def by_id(self, appid): |
|
|
|
|
|
|
|
""" Steam App by AppID """ |
|
|
|
app = self.steam.get_app(appid) |
|
|
|
app = self.steam.get_app(appid) |
|
|
|
if app is not None: |
|
|
|
if app is not None: |
|
|
|
return SyncOp(self, app) |
|
|
|
return SteamSyncOp(self, app) |
|
|
|
else: |
|
|
|
else: |
|
|
|
return AppNotFound |
|
|
|
return AppNotFound |
|
|
|
|
|
|
|
|
|
|
|
def by_name(self, pattern): |
|
|
|
def by_name(self, pattern): |
|
|
|
|
|
|
|
""" Steam App by Name """ |
|
|
|
pt = re.compile(fnmatch.translate(pattern).rstrip("\\Z"), re.IGNORECASE) |
|
|
|
pt = re.compile(fnmatch.translate(pattern).rstrip("\\Z"), re.IGNORECASE) |
|
|
|
app = None |
|
|
|
app = None |
|
|
|
for candidate in self.apps: #pylint:disable=not-an-iterable |
|
|
|
for candidate in self.apps: #pylint:disable=not-an-iterable |
|
|
@ -245,4 +352,12 @@ class SteamSync: |
|
|
|
if app is not None: |
|
|
|
if app is not None: |
|
|
|
raise Exception("Encountered more than one possible App matching '%s'" % pattern) |
|
|
|
raise Exception("Encountered more than one possible App matching '%s'" % pattern) |
|
|
|
app = candidate |
|
|
|
app = candidate |
|
|
|
return SyncOp(self, app) |
|
|
|
if app is None: |
|
|
|
|
|
|
|
return AppNotFound |
|
|
|
|
|
|
|
return SteamSyncOp(self, app) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generic(self, name, *, platform=None): |
|
|
|
|
|
|
|
""" Non-Steam App """ |
|
|
|
|
|
|
|
if platform is not None and platform not in sys.platform: |
|
|
|
|
|
|
|
return AppNotFound |
|
|
|
|
|
|
|
return GenericSyncOp(self, name) |
|
|
|