partial-backup 0.0.1__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich import print
5
+
6
+ from partial_backup.backup import Backup
7
+
8
+
9
+ def create_backup_directory(source: Path, output: Path) -> None:
10
+ """
11
+ Construct a backup directory from a given source directory given in an `unback` format
12
+ """
13
+ output.mkdir(parents=True, exist_ok=True)
14
+ backup = Backup.create_backup_from_directory(source)
15
+ print(f"Constructing the following backup structure into: {output}")
16
+ print(backup)
17
+ backup.write_to_directory(output)
18
+ print("[bold][green]Done![/green][/bold]")
19
+ print("[bold]Consider restoring this backup using:[/bold]")
20
+ print(f"[magenta]pymobiledevice3 backup2 restore {output} --source . --no-copy --system[/magenta]")
21
+
22
+
23
+ def cli() -> None:
24
+ typer.run(create_backup_directory)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ cli()
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.1'
32
+ __version_tuple__ = version_tuple = (0, 0, 1)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,288 @@
1
+ import plistlib
2
+ import re
3
+ from base64 import b64decode
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from hashlib import sha1
7
+ from pathlib import Path
8
+ from random import randbytes
9
+ from typing import Optional
10
+
11
+ from . import mbdb
12
+ from .mbdb import FileMode
13
+
14
+ # RWX:RX:RX
15
+ DEFAULT = (FileMode.S_IRUSR | FileMode.S_IWUSR | FileMode.S_IXUSR | FileMode.S_IRGRP | FileMode.S_IXGRP |
16
+ FileMode.S_IROTH | FileMode.S_IXOTH)
17
+
18
+ IGNORED_NAMES = {".DS_Store", "Thumbs.db", "desktop.ini", ".Spotlight-V100", ".Trashes"}
19
+
20
+
21
+ @dataclass
22
+ class Metadata:
23
+ owner: Optional[int] = None
24
+ group: Optional[int] = None
25
+ mode: Optional[int] = None
26
+
27
+
28
+ META_PATTERN = re.compile(
29
+ r"(user_(?P<user>\d+))?"
30
+ r"_?"
31
+ r"(group_(?P<group>\d+))?"
32
+ r"_?"
33
+ r"(mode_(?P<mode>[0-7]{3}))?"
34
+ )
35
+
36
+ DEFAULT_USER = {
37
+ 'HomeDomain': 501
38
+ }
39
+
40
+ DEFAULT_GROUP = {
41
+ 'HomeDomain': 501
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class BackupFile:
47
+ path: str
48
+ domain: str
49
+
50
+ def to_record(self) -> mbdb.MbdbRecord:
51
+ raise NotImplementedError()
52
+
53
+
54
+ @dataclass
55
+ class ConcreteFile(BackupFile):
56
+ contents: bytes
57
+ owner: int = 0
58
+ group: int = 0
59
+ inode: Optional[int] = None
60
+ mode: FileMode = DEFAULT
61
+
62
+ def to_record(self) -> mbdb.MbdbRecord:
63
+ if self.inode is None:
64
+ self.inode = int.from_bytes(randbytes(8), "big")
65
+ return mbdb.MbdbRecord(
66
+ domain=self.domain,
67
+ filename=self.path,
68
+ link="",
69
+ hash=sha1(self.contents).digest(),
70
+ key=b"",
71
+ mode=self.mode | FileMode.S_IFREG,
72
+ # unknown2=0,
73
+ # unknown3=0,
74
+ inode=self.inode,
75
+ user_id=self.owner,
76
+ group_id=self.group,
77
+ mtime=int(datetime.now().timestamp()),
78
+ atime=int(datetime.now().timestamp()),
79
+ ctime=int(datetime.now().timestamp()),
80
+ size=len(self.contents),
81
+ flags=4,
82
+ properties=[]
83
+ )
84
+
85
+ def __repr__(self) -> str:
86
+ return (f"ConcreteFile(path='{self.path}', domain='{self.domain}', "
87
+ f"contents=b'<... lenngth: {len(self.contents)}>', owner={self.owner}, "
88
+ f"group={self.group}, inode={self.inode}, mode={self.mode})")
89
+
90
+
91
+ @dataclass
92
+ class Directory(BackupFile):
93
+ owner: int = 0
94
+ group: int = 0
95
+ mode: FileMode = DEFAULT
96
+
97
+ def to_record(self) -> mbdb.MbdbRecord:
98
+ return mbdb.MbdbRecord(
99
+ domain=self.domain,
100
+ filename=self.path,
101
+ link="",
102
+ hash=b"",
103
+ key=b"",
104
+ mode=self.mode | FileMode.S_IFDIR,
105
+ # unknown2=0,
106
+ # unknown3=0,
107
+ inode=0, # inode is not respected for directories
108
+ user_id=self.owner,
109
+ group_id=self.group,
110
+ mtime=int(datetime.now().timestamp()),
111
+ atime=int(datetime.now().timestamp()),
112
+ ctime=int(datetime.now().timestamp()),
113
+ size=0,
114
+ flags=4,
115
+ properties=[]
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class SymbolicLink(BackupFile):
121
+ target: str
122
+ owner: int = 0
123
+ group: int = 0
124
+ inode: Optional[int] = None
125
+ mode: FileMode = DEFAULT
126
+
127
+ def to_record(self) -> mbdb.MbdbRecord:
128
+ if self.inode is None:
129
+ self.inode = int.from_bytes(randbytes(8), "big")
130
+ return mbdb.MbdbRecord(
131
+ domain=self.domain,
132
+ filename=self.path,
133
+ link=self.target,
134
+ hash=b"",
135
+ key=b"",
136
+ mode=self.mode | FileMode.S_IFLNK,
137
+ # unknown2=0,
138
+ # unknown3=0,
139
+ inode=self.inode,
140
+ user_id=self.owner,
141
+ group_id=self.group,
142
+ mtime=int(datetime.now().timestamp()),
143
+ atime=int(datetime.now().timestamp()),
144
+ ctime=int(datetime.now().timestamp()),
145
+ size=0,
146
+ flags=4,
147
+ properties=[]
148
+ )
149
+
150
+
151
+ def extract_metadata_and_clean(parts: list[str]) -> tuple[list[str], Metadata]:
152
+ cleaned_parts = []
153
+ meta = Metadata()
154
+
155
+ for part in parts:
156
+ if part in IGNORED_NAMES:
157
+ continue # skip ignored segments entirely
158
+
159
+ match = META_PATTERN.fullmatch(part)
160
+ if match and any(match.group(k) for k in ("user", "group", "mode")):
161
+ if match.group("user"):
162
+ meta.owner = int(match.group("user"))
163
+ if match.group("group"):
164
+ meta.group = int(match.group("group"))
165
+ if match.group("mode"):
166
+ meta.mode = int(match.group("mode"), 8)
167
+ continue # skip from cleaned path
168
+ cleaned_parts.append(part)
169
+
170
+ return cleaned_parts, meta
171
+
172
+
173
+ @dataclass
174
+ class Backup:
175
+ files: list[BackupFile]
176
+
177
+ @classmethod
178
+ def create_backup_from_directory(cls, root: Path) -> 'Backup':
179
+ files: list[Directory | ConcreteFile] = []
180
+ seen_dirs = set()
181
+ root = root.resolve()
182
+
183
+ for domain_dir in root.iterdir():
184
+ if not domain_dir.is_dir():
185
+ continue
186
+ domain = domain_dir.name
187
+
188
+ for path in sorted(domain_dir.rglob("*")):
189
+ if path.name in IGNORED_NAMES or path == domain_dir:
190
+ continue
191
+
192
+ rel_path = path.relative_to(domain_dir)
193
+ cleaned_parts, meta = extract_metadata_and_clean(list(rel_path.parts))
194
+ cleaned_rel_path = Path(*cleaned_parts)
195
+
196
+ if cleaned_rel_path == Path():
197
+ continue # path was only metadata or ignored
198
+
199
+ # Ensure parent directories are created first
200
+ for i in range(1, len(cleaned_parts)):
201
+ parent = Path(*cleaned_parts[:i])
202
+ if parent not in seen_dirs:
203
+ files.append(Directory(str(parent), domain))
204
+ seen_dirs.add(parent)
205
+
206
+ mode = meta.mode if meta.mode is not None else DEFAULT
207
+ owner = meta.owner if meta.owner is not None else DEFAULT_USER.get(domain, 0)
208
+ group = meta.group if meta.group is not None else DEFAULT_GROUP.get(domain, 0)
209
+
210
+ if path.is_dir():
211
+ if cleaned_rel_path not in seen_dirs:
212
+ files.append(Directory(str(cleaned_rel_path), domain, owner=owner, group=group, mode=mode))
213
+ seen_dirs.add(cleaned_rel_path)
214
+ elif path.is_file():
215
+ contents = path.read_bytes()
216
+ files.append(
217
+ ConcreteFile(str(cleaned_rel_path), domain, contents, owner=owner, group=group, mode=mode))
218
+
219
+ return cls(files)
220
+
221
+ def write_to_directory(self, directory: Path):
222
+ for file in self.files:
223
+ if isinstance(file, ConcreteFile):
224
+ with open(directory / sha1((file.domain + "-" + file.path).encode()).digest().hex(), "wb") as f:
225
+ f.write(file.contents)
226
+
227
+ with open(directory / "Manifest.mbdb", "wb") as f:
228
+ f.write(self.generate_manifest_db().to_bytes())
229
+
230
+ with open(directory / "Status.plist", "wb") as f:
231
+ f.write(self.generate_status())
232
+
233
+ with open(directory / "Manifest.plist", "wb") as f:
234
+ f.write(self.generate_manifest())
235
+
236
+ with open(directory / "Info.plist", "wb") as f:
237
+ f.write(plistlib.dumps({}))
238
+
239
+ def generate_manifest_db(self): # Manifest.mbdb
240
+ records = []
241
+ for file in self.files:
242
+ records.append(file.to_record())
243
+ return mbdb.Mbdb(records=records)
244
+
245
+ def generate_status(self) -> bytes: # Status.plist
246
+ return plistlib.dumps({
247
+ "BackupState": "new",
248
+ "Date": datetime.fromisoformat("1970-01-01T00:00:00+00:00"),
249
+ "IsFullBackup": False,
250
+ "SnapshotState": "finished",
251
+ "UUID": "00000000-0000-0000-0000-000000000000",
252
+ "Version": "2.4"
253
+ })
254
+
255
+ def generate_manifest(self) -> bytes: # Manifest.plist
256
+ return plistlib.dumps({
257
+ "BackupKeyBag": b64decode("""
258
+ VkVSUwAAAAQAAAAFVFlQRQAAAAQAAAABVVVJRAAAABDud41d1b9NBICR1BH9JfVtSE1D
259
+ SwAAACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1JBUAAA
260
+ AAQAAAAAU0FMVAAAABRY5Ne2bthGQ5rf4O3gikep1e6tZUlURVIAAAAEAAAnEFVVSUQA
261
+ AAAQB7R8awiGR9aba1UuVahGPENMQVMAAAAEAAAAAVdSQVAAAAAEAAAAAktUWVAAAAAE
262
+ AAAAAFdQS1kAAAAoN3kQAJloFg+ukEUY+v5P+dhc/Welw/oucsyS40UBh67ZHef5ZMk9
263
+ UVVVSUQAAAAQgd0cg0hSTgaxR3PVUbcEkUNMQVMAAAAEAAAAAldSQVAAAAAEAAAAAktU
264
+ WVAAAAAEAAAAAFdQS1kAAAAoMiQTXx0SJlyrGJzdKZQ+SfL124w+2Tf/3d1R2i9yNj9z
265
+ ZCHNJhnorVVVSUQAAAAQf7JFQiBOS12JDD7qwKNTSkNMQVMAAAAEAAAAA1dSQVAAAAAE
266
+ AAAAAktUWVAAAAAEAAAAAFdQS1kAAAAoSEelorROJA46ZUdwDHhMKiRguQyqHukotrxh
267
+ jIfqiZ5ESBXX9txi51VVSUQAAAAQfF0G/837QLq01xH9+66vx0NMQVMAAAAEAAAABFdS
268
+ QVAAAAAEAAAAAktUWVAAAAAEAAAAAFdQS1kAAAAol0BvFhd5bu4Hr75XqzNf4g0fMqZA
269
+ ie6OxI+x/pgm6Y95XW17N+ZIDVVVSUQAAAAQimkT2dp1QeadMu1KhJKNTUNMQVMAAAAE
270
+ AAAABVdSQVAAAAAEAAAAA0tUWVAAAAAEAAAAAFdQS1kAAAAo2N2DZarQ6GPoWRgTiy/t
271
+ djKArOqTaH0tPSG9KLbIjGTOcLodhx23xFVVSUQAAAAQQV37JVZHQFiKpoNiGmT6+ENM
272
+ QVMAAAAEAAAABldSQVAAAAAEAAAAA0tUWVAAAAAEAAAAAFdQS1kAAAAofe2QSvDC2cV7
273
+ Etk4fSBbgqDx5ne/z1VHwmJ6NdVrTyWi80Sy869DM1VVSUQAAAAQFzkdH+VgSOmTj3yE
274
+ cfWmMUNMQVMAAAAEAAAAB1dSQVAAAAAEAAAAA0tUWVAAAAAEAAAAAFdQS1kAAAAo7kLY
275
+ PQ/DnHBERGpaz37eyntIX/XzovsS0mpHW3SoHvrb9RBgOB+WblVVSUQAAAAQEBpgKOz9
276
+ Tni8F9kmSXd0sENMQVMAAAAEAAAACFdSQVAAAAAEAAAAA0tUWVAAAAAEAAAAAFdQS1kA
277
+ AAAo5mxVoyNFgPMzphYhm1VG8Fhsin/xX+r6mCd9gByF5SxeolAIT/ICF1VVSUQAAAAQ
278
+ rfKB2uPSQtWh82yx6w4BoUNMQVMAAAAEAAAACVdSQVAAAAAEAAAAA0tUWVAAAAAEAAAA
279
+ AFdQS1kAAAAo5iayZBwcRa1c1MMx7vh6lOYux3oDI/bdxFCW1WHCQR/Ub1MOv+QaYFVV
280
+ SUQAAAAQiLXvK3qvQza/mea5inss/0NMQVMAAAAEAAAACldSQVAAAAAEAAAAA0tUWVAA
281
+ AAAEAAAAAFdQS1kAAAAoD2wHX7KriEe1E31z7SQ7/+AVymcpARMYnQgegtZD0Mq2U55u
282
+ xwNr2FVVSUQAAAAQ/Q9feZxLS++qSe/a4emRRENMQVMAAAAEAAAAC1dSQVAAAAAEAAAA
283
+ A0tUWVAAAAAEAAAAAFdQS1kAAAAocYda2jyYzzSKggRPw/qgh6QPESlkZedgDUKpTr4Z
284
+ Z8FDgd7YoALY1g=="""),
285
+ "Lockdown": {},
286
+ "SystemDomainsVersion": "20.0",
287
+ "Version": "9.1"
288
+ })
partial_backup/mbdb.py ADDED
@@ -0,0 +1,172 @@
1
+ from dataclasses import dataclass
2
+ # Mode bitfield
3
+ from enum import IntFlag
4
+ from io import BytesIO
5
+
6
+
7
+ class FileMode(IntFlag):
8
+ S_IFMT = 0o0170000
9
+ S_IFIFO = 0o0010000
10
+ S_IFCHR = 0o0020000
11
+ S_IFDIR = 0o0040000
12
+ S_IFBLK = 0o0060000
13
+ S_IFREG = 0o0100000
14
+ S_IFLNK = 0o0120000
15
+ S_IFSOCK = 0o0140000
16
+
17
+ # S_IRWXU = 0o0000700
18
+ S_IRUSR = 0o0000400
19
+ S_IWUSR = 0o0000200
20
+ S_IXUSR = 0o0000100
21
+
22
+ # S_IRWXG = 0o0000070
23
+ S_IRGRP = 0o0000040
24
+ S_IWGRP = 0o0000020
25
+ S_IXGRP = 0o0000010
26
+
27
+ # S_IRWXO = 0o0000007
28
+ S_IROTH = 0o0000004
29
+ S_IWOTH = 0o0000002
30
+ S_IXOTH = 0o0000001
31
+
32
+ S_ISUID = 0o0004000
33
+ S_ISGID = 0o0002000
34
+ S_ISVTX = 0o0001000
35
+
36
+
37
+ @dataclass
38
+ class MbdbRecord:
39
+ domain: str
40
+ filename: str
41
+ link: str
42
+ hash: bytes
43
+ key: bytes
44
+ mode: FileMode
45
+ inode: int
46
+ user_id: int
47
+ group_id: int
48
+ mtime: int
49
+ atime: int
50
+ ctime: int
51
+ size: int
52
+ flags: int
53
+ properties: list
54
+
55
+ @classmethod
56
+ def from_stream(cls, d: BytesIO):
57
+ # d = BytesIO(data)
58
+
59
+ domain_len = int.from_bytes(d.read(2), "big")
60
+ domain = d.read(domain_len).decode("utf-8")
61
+
62
+ filename_len = int.from_bytes(d.read(2), "big")
63
+ filename = d.read(filename_len).decode("utf-8")
64
+
65
+ link_len = int.from_bytes(d.read(2), "big")
66
+ link = d.read(link_len).decode("utf-8") if link_len != 0xffff else ""
67
+
68
+ hash_len = int.from_bytes(d.read(2), "big")
69
+ hash = d.read(hash_len) if hash_len != 0xffff else b""
70
+
71
+ key_len = int.from_bytes(d.read(2), "big")
72
+ key = d.read(key_len) if key_len != 0xffff else b""
73
+
74
+ mode = FileMode(int.from_bytes(d.read(2), "big"))
75
+ # unknown2 = int.from_bytes(d.read(4), "big")
76
+ # unknown3 = int.from_bytes(d.read(4), "big")
77
+ inode = int.from_bytes(d.read(8), "big")
78
+ user_id = int.from_bytes(d.read(4), "big")
79
+ group_id = int.from_bytes(d.read(4), "big")
80
+ mtime = int.from_bytes(d.read(4), "big")
81
+ atime = int.from_bytes(d.read(4), "big")
82
+ ctime = int.from_bytes(d.read(4), "big")
83
+ size = int.from_bytes(d.read(8), "big")
84
+ flags = int.from_bytes(d.read(1), "big")
85
+
86
+ properties_count = int.from_bytes(d.read(1), "big")
87
+ properties = []
88
+
89
+ for _ in range(properties_count):
90
+ name_len = int.from_bytes(d.read(2), "big")
91
+ name = d.read(name_len).decode("utf-8") if name_len != 0xffff else ""
92
+
93
+ value_len = int.from_bytes(d.read(2), "big")
94
+ value = d.read(value_len).decode("utf-8") if value_len != 0xffff else ""
95
+
96
+ properties.append((name, value))
97
+
98
+ return cls(domain, filename, link, hash, key, mode, inode, user_id, group_id, mtime, atime, ctime, size, flags,
99
+ properties)
100
+
101
+ def to_bytes(self) -> bytes:
102
+ d = BytesIO()
103
+
104
+ d.write(len(self.domain).to_bytes(2, "big"))
105
+ d.write(self.domain.encode("utf-8"))
106
+
107
+ d.write(len(self.filename).to_bytes(2, "big"))
108
+ d.write(self.filename.encode("utf-8"))
109
+
110
+ d.write(len(self.link).to_bytes(2, "big"))
111
+ d.write(self.link.encode("utf-8"))
112
+
113
+ d.write(len(self.hash).to_bytes(2, "big"))
114
+ d.write(self.hash)
115
+
116
+ d.write(len(self.key).to_bytes(2, "big"))
117
+ d.write(self.key)
118
+
119
+ d.write(self.mode.to_bytes(2, "big"))
120
+ # d.write(self.unknown2.to_bytes(4, "big"))
121
+ # d.write(self.unknown3.to_bytes(4, "big"))
122
+ d.write(self.inode.to_bytes(8, "big"))
123
+ d.write(self.user_id.to_bytes(4, "big"))
124
+ d.write(self.group_id.to_bytes(4, "big"))
125
+ d.write(self.mtime.to_bytes(4, "big"))
126
+ d.write(self.atime.to_bytes(4, "big"))
127
+ d.write(self.ctime.to_bytes(4, "big"))
128
+ d.write(self.size.to_bytes(8, "big"))
129
+ d.write(self.flags.to_bytes(1, "big"))
130
+
131
+ d.write(len(self.properties).to_bytes(1, "big"))
132
+
133
+ for name, value in self.properties:
134
+ d.write(len(name).to_bytes(2, "big"))
135
+ d.write(name.encode("utf-8"))
136
+
137
+ d.write(len(value).to_bytes(2, "big"))
138
+ d.write(value.encode("utf-8"))
139
+
140
+ return d.getvalue()
141
+
142
+
143
+ @dataclass
144
+ class Mbdb:
145
+ records: list[MbdbRecord]
146
+
147
+ @classmethod
148
+ def from_bytes(cls, data: bytes):
149
+ d = BytesIO(data)
150
+
151
+ if d.read(4) != b"mbdb":
152
+ raise ValueError("Invalid MBDB file")
153
+
154
+ if d.read(2) != b"\x05\x00":
155
+ raise ValueError("Invalid MBDB version")
156
+
157
+ records = []
158
+ while d.tell() < len(data):
159
+ records.append(MbdbRecord.from_stream(d))
160
+
161
+ return cls(records)
162
+
163
+ def to_bytes(self) -> bytes:
164
+ d = BytesIO()
165
+
166
+ d.write(b"mbdb")
167
+ d.write(b"\x05\x00")
168
+
169
+ for record in self.records:
170
+ d.write(record.to_bytes())
171
+
172
+ return d.getvalue()
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: partial-backup
3
+ Version: 0.0.1
4
+ Summary: Metadata-Aware Partial Backup Creator
5
+ Project-URL: Homepage, https://github.com/doronz88/partial-backup
6
+ Project-URL: Bug Reports, https://github.com/doronz88/partial-backup/issues
7
+ Keywords: ios,backup,partial-backup
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: pymobiledevice3>4.14.14
19
+ Requires-Dist: bpylist2
20
+ Requires-Dist: rich
21
+ Requires-Dist: click
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-asyncio; extra == "test"
25
+
26
+ # ๐Ÿ—‚๏ธ partial-backup: Metadata-Aware Partial Backup Creator
27
+
28
+ This utility scans a given directory in a structured *"unbacked"* format and generates a `Backup` object that can be
29
+ written back as a valid backup. It preserves directory and file metadata such as ownership (`user_`), group (`group_`),
30
+ and file mode (`mode_`), and skips common junk files.
31
+
32
+ ---
33
+
34
+ ## ๐Ÿ“ฆ Features
35
+
36
+ - Recursively scans a directory organized by *domain* (e.g., `HomeDomain`)
37
+ - Supports embedded metadata in directory names (`user_501_group_0_mode_755`)
38
+ - Preserves and serializes:
39
+ - File contents
40
+ - Directory structure
41
+ - Ownership and permissions
42
+ - Skips system-specific noise like `.DS_Store` and `Thumbs.db`
43
+ - Outputs a fully reconstructable `Backup` object
44
+ - Simple CLI using [Typer](https://typer.tiangolo.com/)
45
+
46
+ ---
47
+
48
+ ## ๐Ÿ› ๏ธ Installation
49
+
50
+ ```bash
51
+ pip install partial-backup
52
+ ```
53
+
54
+ ## ๐Ÿงช Example Usage
55
+
56
+ Assume your source directory is structured like this:
57
+
58
+ ```none
59
+ unback/HomeDomain/
60
+ โ”œโ”€โ”€ user_501_group_0_mode_777/Library
61
+ โ”‚ โ””โ”€โ”€ Preferences/
62
+ โ”‚ โ””โ”€โ”€ com.example.settings.plist
63
+ ```
64
+
65
+ To generate the backup:
66
+
67
+ ```shell
68
+ partial-backup unback/ output/
69
+ ```
70
+
71
+ It will:
72
+
73
+ - Detect `HomeDomain`
74
+ - Extract user/group/mode from directories like `user_501_group_0_mode_777`
75
+ - Generate a Backup object
76
+ - Save the reconstructed backup to `output/`
77
+
78
+ ## โ™ป๏ธ Restore the Backup
79
+
80
+ The generated backup is compatible with [pymobiledevice3](https://github.com/doronz88/pymobiledevice3), and can be
81
+ restored to an iOS device using:
82
+
83
+ ```shell
84
+ pymobiledevice3 backup2 restore /path/to/backup --source . --no-copy --system
85
+ ```
86
+
87
+ ## ๐Ÿ“ Metadata Syntax
88
+
89
+ This tool supports optional metadata hints in folder names:
90
+
91
+ | Syntax | Meaning --- |
92
+ |------------------------------|----------------------------|
93
+ | `user_501` | File owned by user `501` |
94
+ | `group_20` | File belongs to group `20` |
95
+ | `mode_755` | File or directory mode |
96
+ | `user_501_group_20_mode_644` | All of the above |
97
+
98
+ These metadata markers are automatically removed from paths and applied to the appropriate file or directory entry.
99
+
100
+ ## โ›” Ignored Files
101
+
102
+ The following files and folders are skipped automatically:
103
+
104
+ - `.DS_Store`
105
+ - `Thumbs.db`
106
+ - `desktop.ini`
107
+ - `.Spotlight-V100`
108
+ - `.Trashes`
109
+
110
+ ## API Overview
111
+
112
+ ```python
113
+ from pathlib import Path
114
+ from partial_backup.backup import Backup
115
+
116
+ backup = Backup.create_backup_from_directory(Path('/path/to/unback'))
117
+ backup.write_to_directory(Path('/path/to/output'))
118
+ ```
119
+
120
+ ## ๐Ÿ™ Acknowledgments
121
+
122
+ Huge thanks to [@JJTech0130](https://github.com/JJTech0130) for his insights and foundational work on the backup DB
123
+ implementation.
@@ -0,0 +1,10 @@
1
+ partial_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ partial_backup/__main__.py,sha256=55Wa8JyApyq32kLFLkj_UJJ_FlzRrRx__F84nYjZG60,824
3
+ partial_backup/_version.py,sha256=qf6R-J7-UyuABBo8c0HgaquJ8bejVbf07HodXgwAwgQ,704
4
+ partial_backup/backup.py,sha256=zHOyxSwnCNHRHp7f91lqIJhIFUEKn1re-F4dCspvOWU,10076
5
+ partial_backup/mbdb.py,sha256=I_2G0c-koTJy70pfc1_SWwRFkB-DOVvYb5f9VorpRgo,4825
6
+ partial_backup-0.0.1.dist-info/METADATA,sha256=ZZ9w8w1yqF0jfEQNJEG8T-L8M7V-sWRk9cCpuFy7Z90,3710
7
+ partial_backup-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ partial_backup-0.0.1.dist-info/entry_points.txt,sha256=4fiwhi1tgXBA3otEAJ-RsUPVfjG0JKX22wp6r4cwESo,63
9
+ partial_backup-0.0.1.dist-info/top_level.txt,sha256=loovO1qKYxTvcgAE9Vy12UABh1M_Ly32qKXIbG3By_E,15
10
+ partial_backup-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ partial-backup = partial_backup.__main__:cli
@@ -0,0 +1 @@
1
+ partial_backup