re1sid-lib 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,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: re1sid-lib
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: 09u2h4n
6
+ Author-email: 09u2h4n <09u2h4n.y1lm42@gmail.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: lxml>=6.1.1
9
+ Requires-Dist: pyaxmlparser>=0.3.31
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
File without changes
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "re1sid-lib"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "09u2h4n", email = "09u2h4n.y1lm42@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ "lxml>=6.1.1",
13
+ "pyaxmlparser>=0.3.31",
14
+ ]
15
+
16
+ [project.scripts]
17
+ re1sid-lib = "re1sid_lib:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.7,<0.12.0"]
21
+ build-backend = "uv_build"
@@ -0,0 +1,4 @@
1
+ from downloader import Downloader
2
+ from patcher import Patcher
3
+
4
+ __all__ = ["Downloader", "Patcher"]
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ # Directory where downloaded ReVanced resources (CLI jar + patches bundle) live.
4
+ RESOURCE_DIR = os.path.join(os.getcwd(), ".revanced_res")
5
+
6
+ PATCHES_PATH = os.path.join(RESOURCE_DIR, "patches.rvp")
7
+ CLI_PATH = os.path.join(RESOURCE_DIR, "revanced-cli.jar")
8
+
9
+ # Where patched APKs get written by default.
10
+ OUTPUT_DIR = os.path.join(os.getcwd(), "output")
@@ -0,0 +1,82 @@
1
+ import httpx
2
+ from typing import Generator, Optional, Union
3
+ import os
4
+ import shutil
5
+ from lxml import html
6
+
7
+ from common import PATCHES_PATH, CLI_PATH
8
+
9
+ class Downloader:
10
+ def __init__(self) -> None:
11
+ self.PATCHES_PATH = PATCHES_PATH
12
+ self.CLI_PATH = CLI_PATH
13
+ self.api_url = "https://api.revanced.app/v5/"
14
+
15
+ def __download_common(self, url: str, save: bool = True, filename: Optional[str] = None,
16
+ chunk_size: int = 8192) -> Union[bool, Generator[bytes, None, None]]:
17
+ if filename is None:
18
+ raise ValueError("Filename must be provided when save is True.")
19
+ else:
20
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
21
+ if save:
22
+ with httpx.stream("GET", url, follow_redirects=True) as response:
23
+ response.raise_for_status()
24
+ with open(filename, "wb") as f:
25
+ for chunk in response.iter_bytes(chunk_size=chunk_size):
26
+ f.write(chunk)
27
+ return True
28
+ else:
29
+ # Return an inner generator to keep the outer function's execution immediate when save=True
30
+ def chunk_generator() -> Generator[bytes, None, None]:
31
+ with httpx.stream("GET", url, follow_redirects=True) as response:
32
+ response.raise_for_status()
33
+ for chunk in response.iter_bytes(chunk_size=chunk_size):
34
+ yield chunk
35
+
36
+ return chunk_generator()
37
+
38
+ def download_patches_rvp(self, save: bool = True, chunk_size: int = 8192) -> Union[bool, Generator[bytes, None, None]]:
39
+ filename = self.PATCHES_PATH
40
+ url = f"{self.api_url}patches.rvp"
41
+ return self.__download_common(url, save=save, filename=filename, chunk_size=chunk_size)
42
+
43
+ def download_cli(self, save: bool = True, chunk_size: int = 8192) -> Union[bool, Generator[bytes, None, None]]:
44
+ filename = self.CLI_PATH
45
+ api_url = "https://api.github.com/repos/ReVanced/revanced-cli/releases/latest"
46
+
47
+ response = httpx.get(api_url, follow_redirects=True)
48
+
49
+ if response.status_code == 403:
50
+ repo_url = "https://github.com/ReVanced/revanced-cli/releases/latest"
51
+ response = httpx.get(repo_url, follow_redirects=True)
52
+ download_url = f"https://github.com/ReVanced/revanced-cli/releases/expanded_assets/{str(response.url).split("/")[-1]}"
53
+ response = httpx.get(download_url, follow_redirects=True)
54
+ html_content = html.fromstring(response.text)
55
+ url = f"https://github.com{html_content.xpath('/html/body/div/ul/li[1]/div[1]/a/@href')[0]}"
56
+ response.raise_for_status()
57
+ else:
58
+ response = response.json()
59
+ assets = response.get("assets", [])
60
+ url = None
61
+ for asset in assets:
62
+ name = asset.get("name", "")
63
+ if name.endswith(".jar"):
64
+ url = asset.get("browser_download_url")
65
+ break
66
+ if not url and assets:
67
+ url = assets[0].get("browser_download_url")
68
+
69
+ if not url:
70
+ raise RuntimeError("Could not find a valid download URL in the ReVanced CLI release assets.")
71
+
72
+ return self.__download_common(url, save=save, filename=filename, chunk_size=chunk_size)
73
+
74
+ def download_all(self) -> None:
75
+ shutil.rmtree(".revanced_res", ignore_errors=True)
76
+ self.download_cli()
77
+ self.download_patches_rvp()
78
+
79
+ if __name__ == "__main__":
80
+ downloader = Downloader()
81
+ downloader.download_all()
82
+
@@ -0,0 +1,346 @@
1
+ import subprocess
2
+ import os
3
+ from typing import List, Dict, Any, Union, Optional, Generator
4
+ from pyaxmlparser import APK
5
+
6
+ from common import PATCHES_PATH, CLI_PATH
7
+
8
+
9
+ class Patcher:
10
+ def __init__(self) -> None:
11
+ self.PATCHES_PATH = PATCHES_PATH
12
+ self.CLI_PATH = CLI_PATH
13
+
14
+ def __exec_cmd(
15
+ self, cmd: List[str], stream: bool = False
16
+ ) -> Union[str, Generator[str, None, None]]:
17
+ """
18
+ Executes a command.
19
+
20
+ :param cmd: The command to run as a list of strings.
21
+ :param stream: If True, returns a generator that yields output line-by-line.
22
+ If False, blocks and returns the entire stdout as a string.
23
+ """
24
+ if stream:
25
+
26
+ def line_generator() -> Generator[str, None, None]:
27
+ process = subprocess.Popen(
28
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
29
+ )
30
+
31
+ if process.stdout:
32
+ for line in iter(process.stdout.readline, ""):
33
+ yield line.strip()
34
+
35
+ return_code = process.wait()
36
+ if return_code != 0:
37
+ raise RuntimeError(f"Command failed with exit code {return_code}.")
38
+
39
+ return line_generator()
40
+
41
+ else:
42
+ # Standard blocking execution
43
+ process = subprocess.Popen(
44
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
45
+ )
46
+ stdout, stderr = process.communicate()
47
+ stdout_str = stdout.strip()
48
+ stderr_str = stderr.strip()
49
+
50
+ if process.returncode != 0:
51
+ err_msg = stderr_str if stderr_str else stdout_str
52
+ raise RuntimeError(
53
+ f"Command failed with exit code {process.returncode}. Error: {err_msg}"
54
+ )
55
+ return stdout_str
56
+
57
+ def __get_package_infos(self, package_name: Optional[str] = None) -> str:
58
+ if not os.path.exists(self.PATCHES_PATH) or not os.path.exists(self.CLI_PATH):
59
+ raise FileNotFoundError(
60
+ "Required files not found. Please ensure both patches and CLI are available."
61
+ )
62
+ cmd = [
63
+ "java",
64
+ "-jar",
65
+ self.CLI_PATH,
66
+ "list-patches",
67
+ "--packages",
68
+ "--versions",
69
+ "--options",
70
+ "-b",
71
+ "-p",
72
+ self.PATCHES_PATH,
73
+ ]
74
+ if package_name:
75
+ cmd.append(f"--filter-package-name={package_name}")
76
+ return self.__exec_cmd(cmd)
77
+
78
+ def __parse_package_infos(
79
+ self, package_name: Optional[str] = None
80
+ ) -> List[Dict[str, Any]]:
81
+ """
82
+ Parses the CLI output of the ReVanced list-patches command into
83
+ a structured list of dictionaries.
84
+ """
85
+ text = self.__get_package_infos(package_name)
86
+ patches = []
87
+ current_patch = None
88
+ current_option = None
89
+ current_package = None
90
+
91
+ # Track parser state for multi-line values/descriptions
92
+ state = None # Can be: None, "patch_desc", "opt_desc", "values", "versions"
93
+
94
+ for line in text.splitlines():
95
+ # Strip common logging prefixes if present
96
+ if line.startswith("INFO: "):
97
+ line = line[6:]
98
+
99
+ stripped = line.strip()
100
+
101
+ # Handle empty lines
102
+ if not stripped:
103
+ # Preserve empty lines inside multi-line descriptions
104
+ if state == "patch_desc" and current_patch:
105
+ current_patch["Description"] += "\n"
106
+ elif state == "opt_desc" and current_option:
107
+ current_option["Description"] += "\n"
108
+ continue
109
+
110
+ # Determine indentation level (supporting both tabs and spaces)
111
+ tabs = len(line) - len(line.lstrip("\t"))
112
+ if tabs == 0:
113
+ spaces = len(line) - len(line.lstrip(" "))
114
+ tabs = spaces // 4 # Standard conversion of 4 spaces to 1 tab level
115
+
116
+ # --- LEVEL 0: Main Patch Parameters ---
117
+ if tabs == 0:
118
+ if stripped.startswith("Index:"):
119
+ # Save previous patch before starting a new one
120
+ if current_patch:
121
+ patches.append(current_patch)
122
+
123
+ index_val = int(stripped.split(":", 1)[1].strip())
124
+ current_patch = {
125
+ "Index": index_val,
126
+ "Name": "",
127
+ "Description": "",
128
+ "Enabled": False,
129
+ "Options": [],
130
+ "Compatible packages": [],
131
+ }
132
+ current_option = None
133
+ current_package = None
134
+ state = None
135
+
136
+ elif current_patch:
137
+ if stripped.startswith("Name:"):
138
+ current_patch["Name"] = stripped.split(":", 1)[1].strip()
139
+ state = None
140
+ elif stripped.startswith("Description:"):
141
+ current_patch["Description"] = stripped.split(":", 1)[1].strip()
142
+ state = "patch_desc"
143
+ elif stripped.startswith("Enabled:"):
144
+ current_patch["Enabled"] = (
145
+ stripped.split(":", 1)[1].strip().lower() == "true"
146
+ )
147
+ state = None
148
+ elif stripped in ("Options:", "Compatible packages:"):
149
+ state = None
150
+
151
+ # --- LEVEL 1: Options & Compatible Packages ---
152
+ elif tabs == 1:
153
+ if current_patch:
154
+ if stripped.startswith("Name:"):
155
+ current_option = {
156
+ "Name": stripped.split(":", 1)[1].strip(),
157
+ "Description": "",
158
+ "Required": False,
159
+ "Default": None,
160
+ "Possible values": [],
161
+ "Type": "",
162
+ }
163
+ current_patch["Options"].append(current_option)
164
+ current_package = None
165
+ state = None
166
+
167
+ elif stripped.startswith("Package name:"):
168
+ current_package = {
169
+ "Package name": stripped.split(":", 1)[1].strip(),
170
+ "Compatible versions": [],
171
+ }
172
+ current_patch["Compatible packages"].append(current_package)
173
+ current_option = None
174
+ state = None
175
+
176
+ elif current_option:
177
+ if stripped.startswith("Description:"):
178
+ current_option["Description"] = stripped.split(":", 1)[
179
+ 1
180
+ ].strip()
181
+ state = "opt_desc"
182
+ elif stripped.startswith("Required:"):
183
+ current_option["Required"] = (
184
+ stripped.split(":", 1)[1].strip().lower() == "true"
185
+ )
186
+ state = None
187
+ elif stripped.startswith("Default:"):
188
+ val = stripped.split(":", 1)[1].strip()
189
+ if val.lower() == "true":
190
+ current_option["Default"] = True
191
+ elif val.lower() == "false":
192
+ current_option["Default"] = False
193
+ else:
194
+ current_option["Default"] = val
195
+ state = None
196
+ elif stripped.startswith("Type:"):
197
+ current_option["Type"] = stripped.split(":", 1)[1].strip()
198
+ state = None
199
+ elif stripped.startswith("Possible values:"):
200
+ state = "values"
201
+ else:
202
+ # Append any un-keyed text here to support multi-line descriptions
203
+ if state == "opt_desc":
204
+ current_option["Description"] += "\n" + stripped
205
+
206
+ elif current_package:
207
+ if stripped.startswith("Compatible versions:"):
208
+ state = "versions"
209
+
210
+ # --- LEVEL 2: Nested Lists ---
211
+ elif tabs == 2:
212
+ if state == "values" and current_option:
213
+ current_option["Possible values"].append(stripped)
214
+ elif state == "versions" and current_package:
215
+ current_package["Compatible versions"].append(stripped)
216
+ elif state == "opt_desc" and current_option:
217
+ current_option["Description"] += "\n" + stripped
218
+
219
+ # Append the final patch
220
+ if current_patch:
221
+ patches.append(current_patch)
222
+
223
+ # Clean up excess whitespace from multi-line descriptions
224
+ for patch in patches:
225
+ patch["Description"] = patch["Description"].strip()
226
+ for opt in patch["Options"]:
227
+ opt["Description"] = opt["Description"].strip()
228
+
229
+ return patches
230
+
231
+ def list_patches(
232
+ self, package_name: Optional[str] = None, apk_path: Optional[str] = None
233
+ ) -> List[Dict[str, Any]]:
234
+ """
235
+ Public method to retrieve and parse the list of patches, optionally filtered by a package.
236
+ """
237
+ if apk_path:
238
+ package_info = self.get_apk_info(apk_path)["package_name"]
239
+ return self.__parse_package_infos(package_info)
240
+ return self.__parse_package_infos(package_name)
241
+
242
+ def get_apk_info(self, apk_path: str) -> Dict[str, str]:
243
+ """
244
+ Reads metadata from the APK file and returns its package name and version.
245
+ """
246
+ if not os.path.exists(apk_path):
247
+ raise FileNotFoundError(f"APK file not found at path: {apk_path}")
248
+ apk = APK(apk_path)
249
+ return {"package_name": apk.package, "version_name": apk.version_name}
250
+
251
+ def _format_option_value(self, val: Any) -> str:
252
+ if val is True:
253
+ return "true"
254
+ elif val is False:
255
+ return "false"
256
+ elif isinstance(val, list):
257
+ return f"[{','.join(self._format_option_value(item) for item in val)}]"
258
+ elif val is None:
259
+ return ""
260
+ else:
261
+ return str(val)
262
+
263
+ def patch_apk(
264
+ self,
265
+ apk_path: str,
266
+ output_path: Optional[str] = None,
267
+ enabled_patches: Optional[List[Union[str, int]]] = None,
268
+ disabled_patches: Optional[List[Union[str, int]]] = None,
269
+ options: Optional[Dict[str, Any]] = None,
270
+ exclusive: bool = False,
271
+ force: bool = False,
272
+ bypass_verification: bool = True,
273
+ purge: bool = True,
274
+ stream_output: bool = False,
275
+ ) -> Union[str, Generator[str, None, None]]:
276
+ """
277
+ Patches an APK file using the ReVanced CLI.
278
+
279
+ :param apk_path: Path to the input APK file.
280
+ :param output_path: Path to save the patched APK.
281
+ :param enabled_patches: Names or indices of patches to enable.
282
+ :param disabled_patches: Names or indices of patches to disable.
283
+ :param options: Dict of option values keyed by option keys.
284
+ :param exclusive: If True, only specified enabled patches will be applied.
285
+ :param force: If True, compatibility checks will be bypassed.
286
+ :param bypass_verification: If True, bypass signature/provenance check on RVP files.
287
+ :param purge: If True, purge temporary files directory after patching.
288
+ :param stream_output: If True, returns a generator that yields output line-by-line.
289
+ :return: Standard output of the patch command.
290
+ """
291
+ if not os.path.exists(self.PATCHES_PATH) or not os.path.exists(self.CLI_PATH):
292
+ raise FileNotFoundError(
293
+ "Required files not found. Please ensure both patches and CLI are available."
294
+ )
295
+ if not os.path.exists(apk_path):
296
+ raise FileNotFoundError(f"APK file not found at path: {apk_path}")
297
+
298
+ cmd = ["java", "-jar", self.CLI_PATH, "patch", "-p", self.PATCHES_PATH]
299
+ if bypass_verification:
300
+ cmd.append("-b")
301
+
302
+ if exclusive:
303
+ cmd.append("--exclusive")
304
+ if force:
305
+ cmd.append("-f")
306
+ if output_path:
307
+ cmd.extend(["-o", output_path])
308
+
309
+ if enabled_patches:
310
+ for patch in enabled_patches:
311
+ if isinstance(patch, int):
312
+ cmd.extend(["--ei", str(patch)])
313
+ else:
314
+ cmd.extend(["-e", str(patch)])
315
+
316
+ if disabled_patches:
317
+ for patch in disabled_patches:
318
+ if isinstance(patch, int):
319
+ cmd.extend(["--di", str(patch)])
320
+ else:
321
+ cmd.extend(["-d", str(patch)])
322
+
323
+ if options:
324
+ for key, val in options.items():
325
+ if val is None:
326
+ cmd.append(f"-O{key}")
327
+ else:
328
+ cmd.append(f"-O{key}={self._format_option_value(val)}")
329
+
330
+ if purge:
331
+ cmd.append("--purge")
332
+
333
+ cmd.append(apk_path)
334
+ print(" ".join(cmd))
335
+ return self.__exec_cmd(cmd, stream=stream_output)
336
+
337
+
338
+ if __name__ == "__main__":
339
+ patcher = Patcher()
340
+ # Example usage: List patches for a specific package
341
+ try:
342
+ patches = patcher.list_patches(package_name="com.spotify.music")
343
+ for patch in patches:
344
+ print(patch)
345
+ except Exception as e:
346
+ print(f"Error: {e}")