Skip to content

Commit 31cc7fc

Browse files
committed
Implemented --all for backup/restore, #33
1 parent b5c4782 commit 31cc7fc

File tree

5 files changed

+145
-42
lines changed

5 files changed

+145
-42
lines changed

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ If you only care about the command line tool, I recommend installing with [pipx]
3131
The command line tool can also be run via `python -m osxmetadata`. Running it with no arguments or with --help option will print a help message:
3232

3333
```
34-
Usage: osxmetadata [OPTIONS] FILE
35-
34+
Usage: python -m osxmetadata [OPTIONS] FILE
35+
3636
Read/write metadata from file(s).
3737
3838
Options:
@@ -64,10 +64,16 @@ Options:
6464
-B, --backup Backup FILE attributes. Backup file
6565
'.osxmetadata.json' will be created in same
6666
folder as FILE. Only backs up attributes
67-
known to osxmetadata.
67+
known to osxmetadata unless used with --all.
6868
-R, --restore Restore FILE attributes from backup file.
6969
Restore will look for backup file
7070
'.osxmetadata.json' in same folder as FILE.
71+
Only restores attributes known to
72+
osxmetadata unless used with --all.
73+
-A, --all Process all extended attributes including
74+
those not known to osxmetadata. Use with
75+
--backup/--restore to backup/restore all
76+
extended attributes.
7177
-V, --verbose Print verbose output.
7278
-f, --copyfrom SOURCE_FILE Copy attributes from file SOURCE_FILE.
7379
--files-only Do not apply metadata commands to

osxmetadata/__main__.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def __init__(self, debug=False, files=None):
4848

4949

5050
class MyClickCommand(click.Command):
51-
""" Custom click.Command that overrides get_help() to show additional info """
51+
"""Custom click.Command that overrides get_help() to show additional info"""
5252

5353
def get_help(self, ctx):
5454
help_text = super().get_help(ctx)
@@ -236,7 +236,7 @@ def get_help(self, ctx):
236236
"-B",
237237
help="Backup FILE attributes. "
238238
"Backup file '.osxmetadata.json' will be created in same folder as FILE. "
239-
"Only backs up attributes known to osxmetadata.",
239+
"Only backs up attributes known to osxmetadata unless used with --all.",
240240
is_flag=True,
241241
required=False,
242242
default=False,
@@ -245,11 +245,20 @@ def get_help(self, ctx):
245245
"--restore",
246246
"-R",
247247
help="Restore FILE attributes from backup file. "
248-
"Restore will look for backup file '.osxmetadata.json' in same folder as FILE.",
248+
"Restore will look for backup file '.osxmetadata.json' in same folder as FILE. "
249+
"Only restores attributes known to osxmetadata unless used with --all.",
249250
is_flag=True,
250251
required=False,
251252
default=False,
252253
)
254+
ALL_OPTION = click.option(
255+
"--all",
256+
"-A",
257+
"all_",
258+
is_flag=True,
259+
help="Process all extended attributes including those not known to osxmetadata. "
260+
"Use with --backup/--restore to backup/restore all extended attributes. ",
261+
)
253262
VERBOSE_OPTION = click.option(
254263
"--verbose",
255264
"-V",
@@ -307,6 +316,7 @@ def get_help(self, ctx):
307316
@MIRROR_OPTION
308317
@BACKUP_OPTION
309318
@RESTORE_OPTION
319+
@ALL_OPTION
310320
@VERBOSE_OPTION
311321
@COPY_FROM_OPTION
312322
@FILES_ONLY_OPTION
@@ -330,12 +340,13 @@ def cli(
330340
mirror,
331341
backup,
332342
restore,
343+
all_,
333344
verbose,
334345
copyfrom,
335346
files_only,
336347
pattern,
337348
):
338-
""" Read/write metadata from file(s). """
349+
"""Read/write metadata from file(s)."""
339350

340351
if help_:
341352
click.echo_via_pager(ctx.get_help())
@@ -432,6 +443,7 @@ def cli(
432443
restore,
433444
walk,
434445
files_only,
446+
all_,
435447
)
436448

437449
if walk and os.path.isdir(filename):
@@ -468,6 +480,7 @@ def cli(
468480
restore,
469481
walk,
470482
files_only,
483+
all_,
471484
)
472485

473486

@@ -490,10 +503,11 @@ def process_files(
490503
restore,
491504
walk,
492505
files_only,
506+
all_,
493507
):
494-
""" process list of files, calls process_single_file to process each file
495-
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
496-
Note: expects all attributes passed in parameters to be validated as valid attributes
508+
"""process list of files, calls process_single_file to process each file
509+
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
510+
Note: expects all attributes passed in parameters to be validated as valid attributes
497511
"""
498512
for filename in files:
499513
fpath = pathlib.Path(filename).resolve()
@@ -514,7 +528,7 @@ def process_files(
514528
if verbose:
515529
click.echo(f" Restoring attribute data for {fpath}")
516530
md = osxmetadata.OSXMetaData(fpath)
517-
md._restore_attributes(attr_dict)
531+
md._restore_attributes(attr_dict, all_=all_)
518532
except FileNotFoundError:
519533
click.echo(
520534
f"Missing backup file {backup_file} for {fpath}, skipping restore",
@@ -547,12 +561,9 @@ def process_files(
547561
if verbose:
548562
click.echo(f" Backing up attribute data for {fpath}")
549563
# load the file if it exists, merge new data, then write out the file again
550-
if backup_file.is_file():
551-
backup_data = load_backup_file(backup_file)
552-
else:
553-
backup_data = {}
554-
json_dict = osxmetadata.OSXMetaData(fpath).asdict()
555-
backup_data[pathlib.Path(fpath).name] = json_dict
564+
backup_data = load_backup_file(backup_file) if backup_file.is_file() else {}
565+
backup_dict = osxmetadata.OSXMetaData(fpath).asdict(all_=all_)
566+
backup_data[pathlib.Path(fpath).name] = backup_dict
556567
write_backup_file(backup_file, backup_data)
557568

558569

@@ -572,9 +583,9 @@ def process_single_file(
572583
verbose,
573584
copyfrom,
574585
):
575-
""" process a single file to apply the options
576-
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
577-
Note: expects all attributes passed in parameters to be validated as valid attributes """
586+
"""process a single file to apply the options
587+
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
588+
Note: expects all attributes passed in parameters to be validated as valid attributes"""
578589

579590
md = osxmetadata.OSXMetaData(fpath)
580591

osxmetadata/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.99.14"
1+
__version__ = "0.99.15"

osxmetadata/osxmetadata.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pathlib
1010
import plistlib
1111
import sys
12+
import base64
1213

1314
# plistlib creates constants at runtime which causes pylint to complain
1415
from plistlib import FMT_BINARY # pylint: disable=E0611
@@ -161,10 +162,18 @@ def tz_aware(self, tz_flag):
161162
tz_flag: bool"""
162163
self._tz_aware = tz_flag
163164

164-
def asdict(self):
165-
"""Return dict with all attributes for this file"""
165+
def asdict(self, all_=False, encode=True):
166+
"""Return dict with all attributes for this file
166167
167-
attribute_list = self.list_metadata()
168+
Args:
169+
all_: bool, if True, returns all attributes including those that osxmetadata knows nothing about
170+
encode: bool, if True, encodes values for unknown attributes with base64, otherwise leaves the values as raw bytes
171+
172+
Returns:
173+
dict with attributes for this file
174+
"""
175+
176+
attribute_list = self._list_attributes() if all_ else self.list_metadata()
168177
dict_data = {
169178
"_version": __version__,
170179
"_filepath": self._posix_name,
@@ -194,20 +203,37 @@ def asdict(self):
194203
# get raw value
195204
dict_data[attribute.constant] = self.get_attribute(attribute.name)
196205
except KeyError:
197-
# unknown attribute, ignore it
198-
pass
206+
# an attribute osxmetadata doesn't know about
207+
if all_:
208+
try:
209+
value = self._attrs[attr]
210+
# convert value to base64 encoded ascii
211+
if encode:
212+
value = base64.b64encode(value).decode("ascii")
213+
dict_data[attr] = value
214+
except KeyError as e:
215+
# value disappeared between call to _list_attributes and now
216+
pass
199217
return dict_data
200218

201-
def to_json(self):
202-
"""Returns a string in JSON format for all attributes in this file"""
203-
dict_data = self.asdict()
219+
def to_json(self, all_=False):
220+
"""Returns a string in JSON format for all attributes in this file
221+
222+
Args:
223+
all_: bool; if True, also restores attributes not known to osxmetadata (generated with asdict(all_=True, encode=True) )
224+
"""
225+
dict_data = self.asdict(all_=all_)
204226
return json.dumps(dict_data)
205227

206-
def _restore_attributes(self, attr_dict):
228+
def _restore_attributes(self, attr_dict, all_=False):
207229
"""restore attributes from attr_dict
208230
for each attribute in attr_dict, will set the attribute
209231
will not clear/erase any attributes on file that are not in attr_dict
210-
attr_dict: an attribute dict as produced by OSXMetaData.asdict()"""
232+
233+
Args:
234+
attr_dict: an attribute dict as produced by OSXMetaData.asdict()
235+
all_: bool; if True, also restores attributes not known to osxmetadata (generated with asdict(all_=True, encode=True) )
236+
"""
211237

212238
for key, val in attr_dict.items():
213239
if key.startswith("_"):
@@ -226,8 +252,10 @@ def _restore_attributes(self, attr_dict):
226252
f"expected list for attribute {key} but got {type(val)}"
227253
)
228254
self.set_attribute(key, Tag(val[0], val[1]))
229-
else:
255+
elif key in ATTRIBUTES:
230256
self.set_attribute(key, val)
257+
elif all_:
258+
self._attrs.set(key, base64.b64decode(val))
231259
except Exception as e:
232260
logging.warning(
233261
f"Unable to restore attribute {key} for {self._fname}: {e}"
@@ -474,14 +502,14 @@ def clear_attribute(self, attribute_name):
474502
pass
475503

476504
def _list_attributes(self):
477-
"""list the attributes set on the file"""
505+
"""list all the attributes set on the file"""
478506
return self._attrs.list()
479507

480508
def list_metadata(self):
481509
"""list the Apple metadata attributes set on the file:
482510
e.g. those in com.apple.metadata namespace"""
483511
# also lists com.osxmetadata.test used for debugging
484-
mdlist = self._attrs.list()
512+
mdlist = self._list_attributes()
485513
mdlist = [
486514
md
487515
for md in mdlist

tests/test_cli.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848

4949

5050
def create_file(filepath):
51-
""" create an empty file at filepath """
51+
"""create an empty file at filepath"""
5252
fd = open(filepath, "w+")
5353
fd.close()
5454

@@ -85,8 +85,8 @@ def temp_dir():
8585

8686

8787
def parse_cli_output(output):
88-
""" helper for testing
89-
parse the CLI --list output and return value of all set attributes as dict """
88+
"""helper for testing
89+
parse the CLI --list output and return value of all set attributes as dict"""
9090
import re
9191

9292
results = {}
@@ -412,6 +412,64 @@ def test_cli_backup_restore_2(temp_file):
412412
assert meta.keywords == ["FooBar"]
413413

414414

415+
def test_cli_backup_restore_all(temp_file):
416+
"""Test --backup/--restore with --all"""
417+
import pathlib
418+
from osxmetadata import OSXMetaData, ATTRIBUTES, Tag
419+
from osxmetadata.constants import _BACKUP_FILENAME
420+
from osxmetadata.__main__ import cli
421+
422+
runner = CliRunner()
423+
result = runner.invoke(
424+
cli,
425+
[
426+
"--set",
427+
"tags",
428+
"Foo",
429+
"--set",
430+
"tags",
431+
"Bar",
432+
"--set",
433+
"comment",
434+
"Hello World!",
435+
"--list",
436+
temp_file,
437+
],
438+
)
439+
assert result.exit_code == 0
440+
meta = OSXMetaData(temp_file)
441+
# set a value osxmetadata doesn't know about
442+
meta._attrs.set("com.foo.bar", b"FOOBAR")
443+
444+
# backup
445+
result = runner.invoke(cli, ["--backup", "--all", temp_file])
446+
assert result.exit_code == 0
447+
448+
# clear the attributes to see if they can be restored
449+
meta.clear_attribute("tags")
450+
meta.clear_attribute("comment")
451+
meta._attrs.remove("com.foo.bar")
452+
assert meta.tags == []
453+
assert meta.comment is None
454+
assert "com.foo.bar" not in meta._list_attributes()
455+
456+
# first run restore without --all
457+
result = runner.invoke(cli, ["--restore", temp_file])
458+
assert result.exit_code == 0
459+
assert meta.tags == [Tag("Foo"), Tag("Bar")]
460+
assert meta.comment == "Hello World!"
461+
462+
with pytest.raises(KeyError):
463+
assert meta._attrs["com.foo.bar"] == b"FOOBAR"
464+
465+
# next run restore with --all
466+
result = runner.invoke(cli, ["--restore", "--all", temp_file])
467+
assert result.exit_code == 0
468+
assert meta.tags == [Tag("Foo"), Tag("Bar")]
469+
assert meta.comment == "Hello World!"
470+
assert meta._attrs["com.foo.bar"] == b"FOOBAR"
471+
472+
415473
def test_cli_mirror(temp_file):
416474
import datetime
417475
from osxmetadata import OSXMetaData, Tag
@@ -794,7 +852,7 @@ def test_cli_downloadeddate(temp_file):
794852

795853

796854
def test_cli_walk(temp_dir):
797-
""" test --walk """
855+
"""test --walk"""
798856
import os
799857
import pathlib
800858
from osxmetadata import OSXMetaData, Tag
@@ -818,7 +876,7 @@ def test_cli_walk(temp_dir):
818876

819877

820878
def test_cli_walk_files_only(temp_dir):
821-
""" test --walk with --files-only """
879+
"""test --walk with --files-only"""
822880
import os
823881
import pathlib
824882
from osxmetadata import OSXMetaData, Tag
@@ -844,7 +902,7 @@ def test_cli_walk_files_only(temp_dir):
844902

845903

846904
def test_cli_walk_pattern(temp_dir):
847-
""" test --walk with --pattern """
905+
"""test --walk with --pattern"""
848906
import os
849907
import pathlib
850908
from osxmetadata import OSXMetaData, Tag
@@ -874,7 +932,7 @@ def test_cli_walk_pattern(temp_dir):
874932

875933

876934
def test_cli_walk_pattern_2(temp_dir):
877-
""" test --walk with more than one --pattern """
935+
"""test --walk with more than one --pattern"""
878936
import os
879937
import pathlib
880938
from osxmetadata import OSXMetaData, Tag
@@ -919,7 +977,7 @@ def test_cli_walk_pattern_2(temp_dir):
919977

920978

921979
def test_cli_files_only(temp_dir):
922-
""" test --files-only without --walk """
980+
"""test --files-only without --walk"""
923981
import glob
924982
import os
925983
import pathlib

0 commit comments

Comments
 (0)