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.
- pyludusavi-0.1.0/.gitignore +14 -0
- pyludusavi-0.1.0/LICENSE +21 -0
- pyludusavi-0.1.0/PKG-INFO +156 -0
- pyludusavi-0.1.0/README.md +136 -0
- pyludusavi-0.1.0/pyproject.toml +65 -0
- pyludusavi-0.1.0/src/pyludusavi/__init__.py +15 -0
- pyludusavi-0.1.0/src/pyludusavi/_version.py +6 -0
- pyludusavi-0.1.0/src/pyludusavi/core.py +119 -0
- pyludusavi-0.1.0/src/pyludusavi/discovery.py +71 -0
- pyludusavi-0.1.0/src/pyludusavi/main.py +756 -0
- pyludusavi-0.1.0/src/pyludusavi/models.py +127 -0
pyludusavi-0.1.0/LICENSE
ADDED
|
@@ -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,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
|