pyludusavi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ **/__pycache__/
3
+ *.pyc
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ .cache/
7
+ .venv/
8
+ .venv
9
+ dist/
10
+ build/
11
+ coverage.xml
12
+ .coverage
13
+ *.parquet
14
+ /tmp/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Beall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyludusavi
3
+ Version: 0.1.0
4
+ Summary: A robust, type-safe Python wrapper for the Ludusavi game backup tool.
5
+ Project-URL: Homepage, https://github.com/beallio/pyludusavi
6
+ Project-URL: Repository, https://github.com/beallio/pyludusavi
7
+ Project-URL: Issues, https://github.com/beallio/pyludusavi/issues
8
+ Author-email: David Beall <6121439+beallio@users.noreply.github.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,game-backup,gaming,ludusavi,wrapper
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Games/Entertainment
17
+ Classifier: Topic :: System :: Archiving :: Backup
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pyludusavi
22
+
23
+ A robust, type-safe Python wrapper for the [Ludusavi](https://github.com/mtkennerly/ludusavi) CLI.
24
+
25
+ ## Features
26
+
27
+ - **Broad CLI Coverage**: Supports the core Ludusavi subcommands and commonly used flags.
28
+ - **Linux-First**: Native support for both local binaries and Flatpak.
29
+ - **Type-Safe**: Comprehensive `TypedDict` models for all JSON outputs (Python 3.12+).
30
+ - **Dual-Mode Execution**: Transparently handles binary vs. Flatpak command prefixing.
31
+ - **TDD-Backed**: High-quality implementation with an extensive regression suite.
32
+
33
+ ## Setup
34
+
35
+ For local development, use the project wrapper so virtual environments and tool
36
+ caches stay outside Dropbox:
37
+
38
+ ```bash
39
+ source .envrc
40
+ ./run.sh uv sync
41
+ ```
42
+
43
+ Run validation through the same wrapper:
44
+
45
+ ```bash
46
+ ./run.sh uv run ruff check .
47
+ ./run.sh uv run ty check src/
48
+ ./run.sh uv run pytest
49
+ ```
50
+
51
+ ## Installation
52
+
53
+ Use `uv` when adding the package to another Python project:
54
+
55
+ ```bash
56
+ uv add pyludusavi
57
+ ```
58
+
59
+ ## Quick Start
60
+
61
+ ### Basic Initialization
62
+ The wrapper automatically discovers Ludusavi via `PATH` or Flatpak.
63
+
64
+ ```python
65
+ from pyludusavi import Ludusavi
66
+
67
+ lud = Ludusavi()
68
+ print(f"Ludusavi version: {lud.version()}")
69
+ ```
70
+
71
+ ### Backing Up Games
72
+ Perform a preview scan or a full backup.
73
+
74
+ ```python
75
+ # Preview a backup for a specific game
76
+ result = lud.backup(games=["The Witcher 3"], preview=True)
77
+
78
+ # Access typed data
79
+ for name, game in result.data["games"].items():
80
+ print(f"Game: {name}, Status: {game['change']}")
81
+ ```
82
+
83
+ ### Searching for Games
84
+ Use Steam IDs or fuzzy matching to find games in the manifest.
85
+
86
+ ```python
87
+ # Find by Steam ID
88
+ result = lud.find(steam_id="292030")
89
+
90
+ # Use fuzzy matching
91
+ result = lud.find(games=["Witcher"], fuzzy=True)
92
+ ```
93
+
94
+ ### Advanced Usage
95
+
96
+ #### Flatpak Support
97
+ If you have Ludusavi installed via Flatpak, `pyludusavi` detects it automatically. You can also specify a custom binary path or Flatpak ID:
98
+
99
+ ```python
100
+ lud = Ludusavi(explicit_path="/usr/bin/ludusavi")
101
+ lud = Ludusavi(flatpak_id="com.github.mtkennerly.ludusavi")
102
+ ```
103
+
104
+ #### Custom Config Directory
105
+ ```python
106
+ lud = Ludusavi(config_dir="/home/user/my-ludusavi-config")
107
+ ```
108
+
109
+ #### Bulk API
110
+ For performance-critical bulk operations, use the native `api` subcommand:
111
+
112
+ ```python
113
+ payload = {
114
+ "requests": [
115
+ {"kind": "backup", "games": ["Game 1"]},
116
+ {"kind": "backup", "games": ["Game 2"]}
117
+ ]
118
+ }
119
+ lud.bulk_api(payload)
120
+ ```
121
+
122
+ #### Cloud Sync
123
+ Use the upload/download helpers with the same common options exposed by the CLI.
124
+
125
+ ```python
126
+ lud.cloud_upload(games=["The Witcher 3"], local="/backups", cloud="/cloud", preview=True)
127
+ lud.cloud_download(games=["The Witcher 3"], force=True)
128
+ ```
129
+
130
+ #### Wrap Game Launch
131
+ Ludusavi requires either a direct game name or launcher inference when wrapping a command.
132
+
133
+ ```python
134
+ lud.wrap(["./game.exe", "--windowed"], name="The Witcher 3")
135
+ lud.wrap(["steam", "-applaunch", "292030"], infer="steam", force=True)
136
+ ```
137
+
138
+ #### Game Aliases
139
+ `add_game_alias()` updates Ludusavi's `customGames` configuration using only the Python standard library. It writes the updated config as JSON, which Ludusavi can read as YAML, but this does not preserve existing comments or formatting in `config.yaml`.
140
+
141
+ ## Error Handling
142
+
143
+ - `LudusaviNotFoundError`: Raised if the executable or Flatpak isn't found.
144
+ - `LudusaviExecutionError`: Raised if the process exits with a non-zero code.
145
+ - `LudusaviContractError`: Raised if the CLI output is malformed or non-JSON when expected.
146
+
147
+ ## Dependency Requirements
148
+
149
+ - Python 3.12+
150
+ - uv
151
+ - Ludusavi v0.31.0+
152
+ - pytest, pytest-cov, ruff, and ty for local development
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,136 @@
1
+ # pyludusavi
2
+
3
+ A robust, type-safe Python wrapper for the [Ludusavi](https://github.com/mtkennerly/ludusavi) CLI.
4
+
5
+ ## Features
6
+
7
+ - **Broad CLI Coverage**: Supports the core Ludusavi subcommands and commonly used flags.
8
+ - **Linux-First**: Native support for both local binaries and Flatpak.
9
+ - **Type-Safe**: Comprehensive `TypedDict` models for all JSON outputs (Python 3.12+).
10
+ - **Dual-Mode Execution**: Transparently handles binary vs. Flatpak command prefixing.
11
+ - **TDD-Backed**: High-quality implementation with an extensive regression suite.
12
+
13
+ ## Setup
14
+
15
+ For local development, use the project wrapper so virtual environments and tool
16
+ caches stay outside Dropbox:
17
+
18
+ ```bash
19
+ source .envrc
20
+ ./run.sh uv sync
21
+ ```
22
+
23
+ Run validation through the same wrapper:
24
+
25
+ ```bash
26
+ ./run.sh uv run ruff check .
27
+ ./run.sh uv run ty check src/
28
+ ./run.sh uv run pytest
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ Use `uv` when adding the package to another Python project:
34
+
35
+ ```bash
36
+ uv add pyludusavi
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ### Basic Initialization
42
+ The wrapper automatically discovers Ludusavi via `PATH` or Flatpak.
43
+
44
+ ```python
45
+ from pyludusavi import Ludusavi
46
+
47
+ lud = Ludusavi()
48
+ print(f"Ludusavi version: {lud.version()}")
49
+ ```
50
+
51
+ ### Backing Up Games
52
+ Perform a preview scan or a full backup.
53
+
54
+ ```python
55
+ # Preview a backup for a specific game
56
+ result = lud.backup(games=["The Witcher 3"], preview=True)
57
+
58
+ # Access typed data
59
+ for name, game in result.data["games"].items():
60
+ print(f"Game: {name}, Status: {game['change']}")
61
+ ```
62
+
63
+ ### Searching for Games
64
+ Use Steam IDs or fuzzy matching to find games in the manifest.
65
+
66
+ ```python
67
+ # Find by Steam ID
68
+ result = lud.find(steam_id="292030")
69
+
70
+ # Use fuzzy matching
71
+ result = lud.find(games=["Witcher"], fuzzy=True)
72
+ ```
73
+
74
+ ### Advanced Usage
75
+
76
+ #### Flatpak Support
77
+ If you have Ludusavi installed via Flatpak, `pyludusavi` detects it automatically. You can also specify a custom binary path or Flatpak ID:
78
+
79
+ ```python
80
+ lud = Ludusavi(explicit_path="/usr/bin/ludusavi")
81
+ lud = Ludusavi(flatpak_id="com.github.mtkennerly.ludusavi")
82
+ ```
83
+
84
+ #### Custom Config Directory
85
+ ```python
86
+ lud = Ludusavi(config_dir="/home/user/my-ludusavi-config")
87
+ ```
88
+
89
+ #### Bulk API
90
+ For performance-critical bulk operations, use the native `api` subcommand:
91
+
92
+ ```python
93
+ payload = {
94
+ "requests": [
95
+ {"kind": "backup", "games": ["Game 1"]},
96
+ {"kind": "backup", "games": ["Game 2"]}
97
+ ]
98
+ }
99
+ lud.bulk_api(payload)
100
+ ```
101
+
102
+ #### Cloud Sync
103
+ Use the upload/download helpers with the same common options exposed by the CLI.
104
+
105
+ ```python
106
+ lud.cloud_upload(games=["The Witcher 3"], local="/backups", cloud="/cloud", preview=True)
107
+ lud.cloud_download(games=["The Witcher 3"], force=True)
108
+ ```
109
+
110
+ #### Wrap Game Launch
111
+ Ludusavi requires either a direct game name or launcher inference when wrapping a command.
112
+
113
+ ```python
114
+ lud.wrap(["./game.exe", "--windowed"], name="The Witcher 3")
115
+ lud.wrap(["steam", "-applaunch", "292030"], infer="steam", force=True)
116
+ ```
117
+
118
+ #### Game Aliases
119
+ `add_game_alias()` updates Ludusavi's `customGames` configuration using only the Python standard library. It writes the updated config as JSON, which Ludusavi can read as YAML, but this does not preserve existing comments or formatting in `config.yaml`.
120
+
121
+ ## Error Handling
122
+
123
+ - `LudusaviNotFoundError`: Raised if the executable or Flatpak isn't found.
124
+ - `LudusaviExecutionError`: Raised if the process exits with a non-zero code.
125
+ - `LudusaviContractError`: Raised if the CLI output is malformed or non-JSON when expected.
126
+
127
+ ## Dependency Requirements
128
+
129
+ - Python 3.12+
130
+ - uv
131
+ - Ludusavi v0.31.0+
132
+ - pytest, pytest-cov, ruff, and ty for local development
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "pyludusavi"
3
+ dynamic = ["version"]
4
+ description = "A robust, type-safe Python wrapper for the Ludusavi game backup tool."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "David Beall", email = "6121439+beallio@users.noreply.github.com" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Intended Audience :: Developers",
16
+ "Topic :: Games/Entertainment",
17
+ "Topic :: System :: Archiving :: Backup",
18
+ ]
19
+ keywords = ["ludusavi", "game-backup", "wrapper", "api", "gaming"]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/beallio/pyludusavi"
24
+ Repository = "https://github.com/beallio/pyludusavi"
25
+ Issues = "https://github.com/beallio/pyludusavi/issues"
26
+
27
+ [tool.hatch.version]
28
+ source = "vcs"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/pyludusavi"]
32
+
33
+ [tool.hatch.build.targets.sdist]
34
+ include = [
35
+ "src/pyludusavi",
36
+ "README.md",
37
+ "LICENSE",
38
+ ]
39
+
40
+ [build-system]
41
+ requires = ["hatchling", "hatch-vcs"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.uv]
45
+ cache-dir = "/tmp/pyludusavi/.uv_cache"
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ cache-dir = "/tmp/pyludusavi/.ruff_cache"
50
+
51
+ [tool.pytest.ini_options]
52
+ addopts = "--cov=src --cov-report=term"
53
+ cache_dir = "/tmp/pyludusavi/.pytest_cache"
54
+ pythonpath = ["src"]
55
+
56
+ [tool.coverage.run]
57
+ data_file = "/tmp/pyludusavi/.coverage"
58
+
59
+ [dependency-groups]
60
+ dev = [
61
+ "pytest>=8.0.0",
62
+ "pytest-cov>=5.0.0",
63
+ "ruff>=0.3.0",
64
+ "ty>=0.0.24",
65
+ ]
@@ -0,0 +1,15 @@
1
+ from .main import Ludusavi
2
+ from .core import LudusaviResponse, LudusaviError, LudusaviExecutionError, LudusaviContractError
3
+ from .discovery import find_ludusavi, LudusaviNotFoundError
4
+ from ._version import __version__
5
+
6
+ __all__ = [
7
+ "Ludusavi",
8
+ "LudusaviResponse",
9
+ "LudusaviError",
10
+ "LudusaviExecutionError",
11
+ "LudusaviContractError",
12
+ "find_ludusavi",
13
+ "LudusaviNotFoundError",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,6 @@
1
+ try:
2
+ from importlib.metadata import version, PackageNotFoundError
3
+
4
+ __version__ = version("pyludusavi")
5
+ except PackageNotFoundError:
6
+ __version__ = "unknown"
@@ -0,0 +1,119 @@
1
+ import subprocess
2
+ import json
3
+ import logging
4
+ from typing import Any, Optional, Literal, Dict, TypeVar, Generic
5
+ from dataclasses import dataclass
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class LudusaviError(Exception):
13
+ """Base exception for pyludusavi."""
14
+
15
+ pass
16
+
17
+
18
+ class LudusaviExecutionError(LudusaviError):
19
+ """Raised when the Ludusavi process exits with a non-zero code."""
20
+
21
+ def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str):
22
+ self.command = command
23
+ self.returncode = returncode
24
+ self.stdout = stdout
25
+ self.stderr = stderr
26
+ super().__init__(f"Ludusavi command {command} failed with exit code {returncode}: {stderr}")
27
+
28
+
29
+ class LudusaviContractError(LudusaviError):
30
+ """Raised when the Ludusavi output does not match the expected contract (e.g. invalid JSON)."""
31
+
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class LudusaviResponse(Generic[T]):
37
+ """Container for Ludusavi command responses."""
38
+
39
+ data: T
40
+ raw: Any
41
+ warnings: str
42
+ command: list[str]
43
+
44
+
45
+ class LudusaviExecutor:
46
+ """Engine for executing Ludusavi commands across different modes."""
47
+
48
+ def __init__(self, command_prefix: list[str]):
49
+ self.command_prefix = command_prefix
50
+
51
+ def execute(
52
+ self,
53
+ args: list[str],
54
+ mode: Literal["JSON", "TEXT", "SPAWN", "STDIN_JSON"] = "JSON",
55
+ input_data: Optional[Any] = None,
56
+ timeout: Optional[float] = 30.0,
57
+ env: Optional[Dict[str, str]] = None,
58
+ auto_api: bool = True,
59
+ ) -> Optional[LudusaviResponse]:
60
+ """
61
+ Execute a Ludusavi command.
62
+
63
+ Args:
64
+ args: Subcommand and flags.
65
+ mode: Execution mode (JSON, TEXT, SPAWN, STDIN_JSON).
66
+ input_data: Dictionary to be sent via stdin (only for STDIN_JSON).
67
+ timeout: Maximum time to wait for the process.
68
+ env: Environment variables for the subprocess.
69
+ auto_api: If True, automatically appends --api to JSON/STDIN_JSON modes.
70
+
71
+ Returns:
72
+ LudusaviResponse or None (for SPAWN mode).
73
+ """
74
+ full_cmd = self.command_prefix + args
75
+
76
+ # Append --api if mode involves JSON parsing
77
+ if auto_api and mode in ["JSON", "STDIN_JSON"] and "--api" not in full_cmd:
78
+ full_cmd.append("--api")
79
+
80
+ logger.debug(f"Executing Ludusavi command: {full_cmd}")
81
+
82
+ if mode == "SPAWN":
83
+ subprocess.Popen(full_cmd, env=env)
84
+ return None
85
+
86
+ stdin_content = None
87
+ if mode == "STDIN_JSON" and input_data is not None:
88
+ stdin_content = json.dumps(input_data)
89
+
90
+ try:
91
+ result = subprocess.run(
92
+ full_cmd,
93
+ input=stdin_content,
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=timeout,
97
+ env=env,
98
+ )
99
+ except subprocess.TimeoutExpired as e:
100
+ raise LudusaviError(f"Ludusavi command timed out after {timeout}s: {full_cmd}") from e
101
+
102
+ if result.returncode != 0:
103
+ raise LudusaviExecutionError(full_cmd, result.returncode, result.stdout, result.stderr)
104
+
105
+ if mode in ["JSON", "STDIN_JSON"]:
106
+ try:
107
+ data = json.loads(result.stdout)
108
+ return LudusaviResponse(
109
+ data=data, raw=data, warnings=result.stderr, command=full_cmd
110
+ )
111
+ except json.JSONDecodeError as e:
112
+ raise LudusaviContractError(
113
+ f"Failed to parse Ludusavi JSON output: {result.stdout}"
114
+ ) from e
115
+
116
+ # mode == "TEXT"
117
+ return LudusaviResponse(
118
+ data=result.stdout, raw=result.stdout, warnings=result.stderr, command=full_cmd
119
+ )
@@ -0,0 +1,71 @@
1
+ import shutil
2
+ import subprocess
3
+ from typing import Optional
4
+
5
+
6
+ class LudusaviNotFoundError(Exception):
7
+ """Raised when the Ludusavi executable or Flatpak could not be found."""
8
+
9
+ pass
10
+
11
+
12
+ def find_ludusavi(
13
+ explicit_path: Optional[str] = None,
14
+ explicit_flatpak_id: Optional[str] = None,
15
+ flatpak_id: str = "com.github.mtkennerly.ludusavi",
16
+ ) -> list[str]:
17
+ """
18
+ Find the Ludusavi executable or Flatpak.
19
+
20
+ Precedence:
21
+ 1. Explicit path.
22
+ 2. Explicit Flatpak ID.
23
+ 3. PATH lookup.
24
+ 4. Default Flatpak ID lookup.
25
+
26
+ Returns:
27
+ list[str]: The command prefix to use for calling Ludusavi.
28
+
29
+ Raises:
30
+ LudusaviNotFoundError: If Ludusavi could not be found or verified.
31
+ """
32
+ # 1. Explicit path
33
+ if explicit_path:
34
+ if _verify([explicit_path]):
35
+ return [explicit_path]
36
+ raise LudusaviNotFoundError(
37
+ f"Explicitly provided Ludusavi path not found or invalid: {explicit_path}"
38
+ )
39
+
40
+ # 2. Explicit Flatpak ID
41
+ if explicit_flatpak_id:
42
+ prefix = ["flatpak", "run", explicit_flatpak_id]
43
+ if shutil.which("flatpak") and _verify(prefix):
44
+ return prefix
45
+ raise LudusaviNotFoundError(
46
+ f"Explicitly provided Ludusavi Flatpak ID not found or invalid: {explicit_flatpak_id}"
47
+ )
48
+
49
+ # 3. PATH lookup
50
+ path_lookup = shutil.which("ludusavi")
51
+ if path_lookup:
52
+ if _verify([path_lookup]):
53
+ return [path_lookup]
54
+
55
+ # 4. Flatpak ID lookup
56
+ flatpak_lookup = shutil.which("flatpak")
57
+ if flatpak_lookup:
58
+ prefix = ["flatpak", "run", flatpak_id]
59
+ if _verify(prefix):
60
+ return prefix
61
+
62
+ raise LudusaviNotFoundError("Ludusavi could not be found via PATH or Flatpak.")
63
+
64
+
65
+ def _verify(prefix: list[str]) -> bool:
66
+ """Verify that the command prefix correctly calls Ludusavi."""
67
+ try:
68
+ result = subprocess.run(prefix + ["--version"], capture_output=True, text=True, check=False)
69
+ return result.returncode == 0
70
+ except (FileNotFoundError, PermissionError):
71
+ return False