Skip to content

Commit 599735b

Browse files
authored
Merge pull request #112 from RhetTbull/fix_tag_color_remove_106
Fix tag color remove 106
2 parents a4c32fe + af5167d commit 599735b

File tree

7 files changed

+111
-25
lines changed

7 files changed

+111
-25
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ scratch/
1717
venv/
1818
.python-version
1919
.coverage
20-
working/
20+
pyrightconfig.json

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Once you've installed osxmetadata with pip, to upgrade to the latest version:
5151
OSXMetaData uses setuptools, thus simply run:
5252

5353
git clone https://github.com/RhetTbull/osxmetadata.git
54-
cd osxmetadata
54+
cd osxmetadata
5555
pip install poetry
5656
poetry install
5757

@@ -137,9 +137,14 @@ True
137137

138138
The class attributes are handled dynamically which, unfortunately, means that IDEs like PyCharm and Visual Studio Code cannot provide tab-completion for them.
139139

140+
> [!NOTE]
141+
> When writing or updating metadata with OSXMetaData, the OS will take some time to update the metadata on disk; in my testing, this can be as short as 100ms or as long as 3s. This means that if you read the metadata immediately after writing it, you may not see the updated metadata. If your use case requires the use of immediate read after write, you may need to implement a delay in your code to allow the OS time to update the metadata on disk. This appears to be an OS limitation and not something that can be controlled by osxmetadata.
142+
143+
```pycon
144+
140145
## Finder Tags
141146

142-
Unlike other attributes, which are mapped to native Python types appropriate for the source Objective C type, Finder tags (`_kMDItemUserTags` or `tags`) have two components: a name (str) and a color ID (unsigned int in range 0 to 7) representing a color tag in the Finder. Reading tags returns a list of `Tag` namedtuples and setting tags requires a list of `Tag` namedtuples.
147+
Unlike other attributes, which are mapped to native Python types appropriate for the source Objective C type, Finder tags (`_kMDItemUserTags` or `tags`) have two components: a name (str) and a color ID (unsigned int in range 0 to 7) representing a color tag in the Finder. Reading tags returns a list of `Tag` namedtuples and setting tags requires a list of `Tag` namedtuples.
143148

144149
```pycon
145150
>>> from osxmetadata import *
@@ -149,7 +154,7 @@ Unlike other attributes, which are mapped to native Python types appropriate for
149154
[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
150155
>>> md.get("_kMDItemUserTags")
151156
[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
152-
>>>
157+
>>>
153158
```
154159

155160
Tag names (but not colors) can also be accessed through the [NSURLTagNamesKey](https://developer.apple.com/documentation/foundation/nsurltagnameskey) resource key and the label color ID is accessible through `NSURLLabelNumberKey`; the localized label color name is accessible through `NSURLLocalizedLabelKey` though these latter two resource keys only return a single color whereas a file may have more than one color tag. For most purposes, I recommend using the `tags` attribute as it is more convenient and provides access to both the name and color ID of the tag.
@@ -224,7 +229,7 @@ Metadata attributes which return date/times such as `kMDItemDueDate` or `kMDItem
224229
>>> md.kMDItemDueDate
225230
datetime.datetime(2022, 10, 1, 0, 0)
226231
>>> md.kMDItemDownloadedDate = datetime.datetime(2022, 10, 1, tzinfo=datetime.timezone.utc)
227-
>>>
232+
>>>
228233
```
229234

230235
## Extended Attributes

osxmetadata/__main__.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,15 @@ def get_attribute_type(attr: str) -> t.Optional[str]:
8484
return (
8585
"list"
8686
if attr in _TAGS_NAMES
87-
else MDITEM_ATTRIBUTE_DATA[attr]["python_type"]
88-
if attr in MDITEM_ATTRIBUTE_DATA
89-
else "int"
90-
if attr == _kFinderColor
91-
else "bool"
92-
if attr == _kFinderStationeryPad
93-
else None
87+
else (
88+
MDITEM_ATTRIBUTE_DATA[attr]["python_type"]
89+
if attr in MDITEM_ATTRIBUTE_DATA
90+
else (
91+
"int"
92+
if attr == _kFinderColor
93+
else "bool" if attr == _kFinderStationeryPad else None
94+
)
95+
)
9496
)
9597

9698

@@ -396,6 +398,7 @@ def md_remove_metadata_with_error(
396398
return f"remove is not a valid operation for single-value attribute {attr}"
397399

398400
if attr in _TAGS_NAMES:
401+
val = md_tag_value_from_file(md, val)
399402
val = tag_factory(val)
400403
elif attr in MDITEM_ATTRIBUTE_DATA or attr in MDITEM_ATTRIBUTE_SHORT_NAMES:
401404
val = str_to_mditem_type(attr, val)
@@ -413,6 +416,24 @@ def md_remove_metadata_with_error(
413416
raise e
414417

415418

419+
def md_tag_value_from_file(md: OSXMetaData, value: str) -> str:
420+
"""Given a tag value, return the tag + color if tag value contains color.
421+
If not, check if file has the same tag and if so, return the tag + color from the file
422+
423+
Returns the new tag value
424+
"""
425+
values = value.split(",")
426+
if len(values) > 2:
427+
raise ValueError(f"More than one value found after comma: {value}")
428+
if len(values) == 2:
429+
return value
430+
if file_tags := md.get(_kMDItemUserTags):
431+
for tag in file_tags:
432+
if tag.name.lower() == value.lower():
433+
return f"{value},{tag.color}"
434+
return value
435+
436+
416437
def md_mirror_metadata_with_error(
417438
md: OSXMetaData, attributes: t.Tuple[t.Tuple[str, str]], verbose: bool
418439
) -> t.Optional[str]:
@@ -668,6 +689,18 @@ def get_help(self, ctx):
668689
# add to list
669690
attr_tuples.append((short_name, attr_help))
670691

692+
# add findercolor which isn't a standard kMDx item
693+
attr_tuples.append(
694+
(
695+
"findercolor",
696+
"findercolor; Finder color tag value. "
697+
+ "The value can be either a number or the name of the color as follows: "
698+
+ f"{', '.join([f'{colorid}: {color}' for color, colorid in _COLORNAMES_LOWER.items() if colorid != FINDER_COLOR_NONE])}; "
699+
+ "integer or string.",
700+
)
701+
)
702+
attr_tuples = sorted(attr_tuples)
703+
671704
formatter.write("\n\n")
672705
formatter.write_text(
673706
"Valid attributes for ATTRIBUTE: "
@@ -704,12 +737,7 @@ def get_help(self, ctx):
704737
+ f"{', '.join([color for color, colorid in _COLORNAMES_LOWER.items() if colorid != FINDER_COLOR_NONE])}. "
705738
+ "If color is not specified but a tag of the same name has already been assigned a color "
706739
+ "in the Finder, the same color will automatically be assigned. "
707-
)
708-
formatter.write("\n")
709-
formatter.write_text(
710-
"com.apple.FinderInfo (finderinfo) value is a key:value dictionary. "
711-
+ "To set finderinfo, pass value in format key1:value1,key2:value2,etc. "
712-
+ "For example: 'osxmetadata --set finderinfo color:2 file.ext'."
740+
+ "See also findercolor."
713741
)
714742
formatter.write("\n")
715743

@@ -1086,7 +1114,8 @@ def process_single_file(
10861114
):
10871115
"""process a single file to apply the options
10881116
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
1089-
Note: expects all attributes passed in parameters to be validated as valid attributes"""
1117+
Note: expects all attributes passed in parameters to be validated as valid attributes
1118+
"""
10901119

10911120
md = OSXMetaData(fpath)
10921121

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
SNOOZE_TIME = 0.5 if os.environ.get("GITHUB_ACTION") else 0.1
1818
# Finder comments need more time to be written to disk
1919
FINDER_COMMENT_SNOOZE = 2.0
20+
LONG_SNOOZE = 3.0 # some tests need a longer snooze time
2021

2122

2223
def snooze(seconds: float = SNOOZE_TIME) -> None:

tests/test_cli.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import datetime
44
import glob
5+
import json
56
import os
67
import pathlib
78

@@ -13,7 +14,7 @@
1314
from osxmetadata.__main__ import BACKUP_FILENAME, cli
1415
from osxmetadata.backup import load_backup_file
1516

16-
from .conftest import FINDER_COMMENT_SNOOZE, snooze
17+
from .conftest import FINDER_COMMENT_SNOOZE, LONG_SNOOZE, snooze
1718

1819

1920
def parse_cli_output(output):
@@ -264,8 +265,13 @@ def test_cli_remove(test_file):
264265
md = OSXMetaData(test_file.name)
265266
md.authors = ["John Doe", "Jane Doe"]
266267
md.tags = [Tag("test", 0)]
268+
snooze()
267269

268270
runner = CliRunner()
271+
result = runner.invoke(cli, ["--list", "--json", test_file.name])
272+
data = json.loads(result.output)
273+
assert sorted(data["kMDItemAuthors"]) == ["Jane Doe", "John Doe"]
274+
269275
result = runner.invoke(
270276
cli,
271277
[
@@ -275,14 +281,40 @@ def test_cli_remove(test_file):
275281
"--remove",
276282
"tags",
277283
"test,0",
284+
"--verbose",
278285
test_file.name,
279286
],
280287
)
281-
snooze()
282288
assert result.exit_code == 0
289+
assert "Removing John Doe from authors" in result.output
290+
# for some reason this test fails without an additional delay
291+
# for the removed metadata to be updated on disk
292+
# without the additional delay, reading the metadata reads the previous value
293+
snooze(LONG_SNOOZE)
294+
295+
result = runner.invoke(cli, ["--list", "--json", test_file.name])
296+
data = json.loads(result.output)
297+
assert data["kMDItemAuthors"] == ["Jane Doe"]
298+
299+
300+
def test_cli_remove_tags_without_color(test_file):
301+
"""Test --remove tags without specifying color (#106)"""
302+
303+
runner = CliRunner()
304+
result = runner.invoke(cli, ["--set", "tags", ".Test,red", test_file.name])
305+
snooze(LONG_SNOOZE)
283306

284307
md = OSXMetaData(test_file.name)
285-
assert md.authors == ["Jane Doe"]
308+
assert md.tags == [Tag(".Test", 6)]
309+
310+
result = runner.invoke(
311+
cli,
312+
["--remove", "tags", ".Test", test_file.name],
313+
)
314+
assert result.exit_code == 0
315+
316+
snooze(LONG_SNOOZE)
317+
md = OSXMetaData(test_file.name)
286318
assert not md.tags
287319

288320

@@ -495,14 +527,15 @@ def test_cli_backup_restore(test_dir):
495527

496528
# wipe the data
497529
result = runner.invoke(cli, ["--wipe", test_file.as_posix()])
498-
snooze()
530+
snooze(LONG_SNOOZE)
499531
md = OSXMetaData(test_file)
500532
assert not md.tags
501533
assert not md.authors
502534
assert not md.stationerypad
503535

504536
# restore the data
505537
result = runner.invoke(cli, ["--restore", test_file.as_posix()])
538+
snooze(LONG_SNOOZE)
506539
assert result.exit_code == 0
507540
assert md.tags == [Tag("test", 0)]
508541
assert md.authors == ["John Doe", "Jane Doe"]
@@ -556,6 +589,7 @@ def test_cli_order(test_dir):
556589
md.wherefroms = ["http://www.apple.com"]
557590
md.downloadeddate = [datetime.datetime(2019, 1, 1, 0, 0, 0)]
558591
md.findercomment = "Hello World"
592+
snooze(LONG_SNOOZE)
559593

560594
runner = CliRunner()
561595

@@ -564,7 +598,7 @@ def test_cli_order(test_dir):
564598

565599
# wipe the data
566600
runner.invoke(cli, ["--wipe", test_file.as_posix()])
567-
snooze()
601+
snooze(LONG_SNOOZE)
568602

569603
# restore the data and check order of operations
570604
result = runner.invoke(
@@ -602,7 +636,7 @@ def test_cli_order(test_dir):
602636
output = parse_cli_output(result.output)
603637
assert output["comment"] == "Hello World"
604638

605-
snooze()
639+
snooze(LONG_SNOOZE)
606640
md = OSXMetaData(test_file)
607641
assert md.authors == ["John Smith", "Jane Smith"]
608642
assert md.findercomment == "Hello World"

tests/test_datetime_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Test datetime_utils """
2+
23
import datetime
34
import os
45
from datetime import date, timezone

tests/test_mditem_attributes.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,19 @@ def test_get_set_mditem_attribute_value(test_file):
173173
snooze()
174174
assert md.get_mditem_attribute_value("kMDItemComment") == "foo,bar"
175175
assert md.comment == "foo,bar"
176+
177+
178+
def test_attribute_get_set(test_file):
179+
"""Test direct access get/set attribute values"""
180+
181+
md = OSXMetaData(test_file.name)
182+
assert not md.authors
183+
md.authors = ["foo", "bar"]
184+
snooze()
185+
assert md.authors == ["foo", "bar"]
186+
md.authors = ["bar"]
187+
snooze()
188+
assert md.authors == ["bar"]
189+
md.set("authors", ["foo"])
190+
snooze()
191+
assert md.authors == ["foo"]

0 commit comments

Comments
 (0)