/
filetags.py
executable file
·1111 lines (969 loc) · 35.5 KB
/
filetags.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" filetags.py
View, set, search, or remove xdg tags and comments from one or more files.
With --nocolor and --search, it allows for operating on files with certain
tags like this in BASH (where echo could be some other command):
for fname in $(filetags -s python -n -R); do
echo "Found $fname"
done
Or this:
find -name "*.py" -exec filetags -n -s "script" "{}" +
-Christopher Welborn 09-27-2015
"""
# TODO:? Add comments and tags at the same time:
# TODO:.. filetags -a "mytag" -m "my message" FILES...
import errno
import inspect
import os
import re
import sys
from contextlib import suppress
from enum import Enum
from pathlib import Path
import xattr
from colr import (
auto_disable as colr_auto_disable,
disable as colr_disable,
docopt,
Colr as C,
)
colr_auto_disable()
NAME = 'File Tags'
VERSION = '0.2.2'
VERSIONSTR = '{} v. {}'.format(NAME, VERSION)
SCRIPT = os.path.split(os.path.abspath(sys.argv[0]))[1]
SCRIPTDIR = os.path.abspath(sys.path[0])
USAGESTR = """{versionstr}
Usage:
{script} -h | -v
{script} [-A | -c | -t] (FILE... | [-R]) [-i]
[-l] [-D | -F] [-I | -q] [-N]
{script} -a tag (FILE... | [-R])
[-l] [-D | -F] [-I | -q] [-N]
{script} -d tag (FILE... | [-R])
[-l] [-D | -F] [-I | -q] [-N]
{script} -m comment (FILE... | [-R])
[-l] [-D | -F] [-I | -q] [-N]
{script} -C [-c] (FILE... | [-R])
[-l] [-D | -F] [-I | -q] [-N]
{script} -s pat [-c] [-n] [-r] [-R]
[-l] [-D | -F] [-I | -q] [-N]
{script} -s pat [-c] FILE... [-n] [-r]
[-l] [-D | -F] [-I | -q] [-N]
Options:
FILE : One or more file names.
When not given, all paths in the current
directory are used. If -R is given instead
of FILES, the current directory is walked.
If - is given as a file name, stdin is read
and each line will be used as a file name.
Files and directories can be filtered
with -F and -D.
MSG : New comment message when setting comments.
-a tag,--add tag : Add a tag to existing tags.
Several comma-separated tags can be used.
-A,--attrs : List all extended attributes.
-c,--comment : List file comments,
search comments when -s is used,
clear comments when -C is used.
-C,--clear : Clear all tags when -t is used,
or comments when -c is used.
-d tag,--delete tag : Remove an existing tag.
Several comma-separated tags can be used.
-D,--dirs : Use directories only.
-F,--files : Use files only.
-h,--help : Show this help message.
-i,--noblanks : Omit files that are missing attrs, tags,
or comments when -A, -c, or -t is used.
-I,--debug : Print debugging info.
-l,--symlinks : Follow symlinks.
-m msg,--setcomment msg : Set the comment for a file.
-n,--names : Print names only when searching.
-N,--nocolor : Don't colorize output.
This is automatically enabled when piping
output.
-q,--quiet : Don't print anything to stdout.
Error messages are still printed to stderr.
This affects all commands, including the
list commands.
-r,--reverse : Show files that don't match the search.
-R,--recurse : Recurse all sub-directories and files.
-s pat,--search pat : Search for text/regex pattern in tags,
or comments when -c is used.
-t,--tags : List all tags.
-v,--version : Show version.
The default action when no flag arguments are present is to list all tags.
When no file names are given, files and directories in the current
directory are used. When -R is given, the current directory is recursed.
""".format(script=SCRIPT, versionstr=VERSIONSTR)
# Global debug flag, set with --debug to print messages.
DEBUG = False
# Global silence flag, set with --quiet to avoid non-error messages.
QUIET = False
def main(argd):
""" Main entry point, expects doctopt arg dict as argd. """
pathfilter = PathFilter.from_argd(argd)
filenames = parse_filenames(
argd['FILE'],
pathfilter=pathfilter
)
if filenames:
print(format_file_cnt('path', len(filenames), label='Using'))
elif filenames is None:
filenames = get_filenames(
recurse=argd['--recurse'],
pathfilter=pathfilter)
else:
# User passed arguments, and none were valid.
print_err('No paths to work with!')
return 1
Editor.follow_symlinks = argd['--symlinks']
if argd['--search']:
return search(
comments=argd['--comment'],
filenames=filenames,
pattern=argd['--search'],
names_only=argd['--names'],
reverse=argd['--reverse']
)
if argd['--add']:
return add_tag(filenames, argd['--add'])
elif argd['--attrs']:
return list_attrs(filenames, ignore_empty=argd['--noblanks'])
elif argd['--clear']:
if argd['--comment']:
return clear_comment(filenames)
return clear_tag(filenames)
elif argd['--comment']:
return list_comments(filenames, ignore_empty=argd['--noblanks'])
elif argd['--delete']:
return remove_tag(filenames, argd['--delete'])
elif argd['--setcomment']:
return set_comment(filenames, argd['--setcomment'])
elif argd['--tags']:
return list_tags(filenames, ignore_empty=argd['--noblanks'])
# Default behavior
return list_tags(filenames, ignore_empty=argd['--noblanks'])
def add_tag(filenames, tagstr):
""" Add a tag or tags to file names.
Return the number of errors.
"""
errs = 0
try:
tags = Editor.parse_tagstr(tagstr)
except ValueError as ex:
print_err(ex)
return 1
for filename in filenames:
editor = Editor(filename)
try:
settags = editor.add_tags(tags)
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
status(
format_file_tags(
editor.filepath,
settags,
label='Set tags for'))
return errs
def clear_comment(filenames):
""" Clear all comments from file names.
Return the number of errors.
"""
return clear_xattr(
filenames,
attrname=Editor.attr_comment)
def clear_tag(filenames):
""" Clear all tags from file names.
Return the number of errors.
"""
return clear_xattr(
filenames,
attrname=Editor.attr_tags)
def clear_xattr(filenames, attrname=None):
""" Clear an entire extended attribute setting from file names.
Return the number of errors.
"""
if not attrname:
print_err('No extended attribute name given!')
return 1
attrtype = attrname.split('.')[-1]
errs = 0
for filename in filenames:
editor = Editor(filename)
try:
editor.remove_attr(attrname)
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
# Tags were removed, or did not exist.
status(format_file_name(
editor.filepath,
label='Cleared {} for'.format(attrtype)))
return errs
def debug(*args, **kwargs):
""" Print a message only if DEBUG is truthy. """
if not (DEBUG and args):
return None
# Include parent class name when given.
parent = kwargs.get('parent', None)
with suppress(KeyError):
kwargs.pop('parent')
# Go back more than once when given.
backlevel = kwargs.get('back', 1)
with suppress(KeyError):
kwargs.pop('back')
frame = inspect.currentframe()
# Go back a number of frames (usually 1).
while backlevel > 0:
frame = frame.f_back
backlevel -= 1
fname = os.path.split(frame.f_code.co_filename)[-1]
lineno = frame.f_lineno
if parent:
func = '{}.{}'.format(parent.__class__.__name__, frame.f_code.co_name)
else:
func = frame.f_code.co_name
# Patch args to stay compatible with print().
pargs = list(args)
# Colorize the line info.
fname = C(fname, fore='yellow')
lineno = C(str(lineno), fore='blue', style='bright')
func = C(func, fore='magenta')
lineinfo = '{}:{} {}(): '.format(fname, lineno, func).ljust(40)
# Join and colorize the message.
sep = kwargs.get('sep', None)
if sep is not None:
kwargs.pop('sep')
else:
sep = ' '
msg = ' '.join((
lineinfo,
str(C(sep.join(pargs), fore='green'))
))
# Format an exception.
ex = kwargs.get('ex', None)
if ex is not None:
kwargs.pop('ex')
exmsg = str(C(str(ex), fore='red'))
msg = '\n '.join((msg, exmsg))
return status(msg, **kwargs)
def format_file_attrs(filename, attrvals):
""" Return a formatted file name and attribute name/values dict
as str.
"""
valfmt = '{:>35}: {}'.format
vals = '\n '.join(
valfmt(C(aname, fore='green'), C(aval, fore='cyan'))
for aname, aval in attrvals.items()
)
if not vals:
vals = C('none', fore='red').join('(', ')', style='bright')
return '{}:\n {}'.format(
format_file_name(filename),
vals)
def format_file_comment(filename, comment, label=None):
""" Return a formatted file name and comment. """
if not comment:
comment = str(
C('empty', fore='red').join('(', ')', style='bright')
)
return '{}:\n {}'.format(
format_file_name(filename, label=label),
'\n '.join(l for l in comment.splitlines())
)
def format_file_name(filename, label=None):
""" Return a formatted file name string. """
style = 'bright' if os.path.isdir(filename) else 'normal'
return ''.join((
'{} '.format(label) if label else '',
str(C(filename, fore='blue', style=style))
))
def format_file_tags(filename, taglist, label=None):
""" Return a formatted file name and tags. """
return '{}:\n {}'.format(
format_file_name(filename, label=label),
format_tags(taglist)
)
def format_file_cnt(filetype, total, label='Found'):
""" Return a formatted search result string. """
if total != 1:
filetype = '{}s'.format(filetype)
return '{}.'.format(
C(' ').join(
C(label or 'Found', fore='cyan'),
C(str(total), fore='blue', style='bright'),
C(filetype, fore='cyan')
)
)
def format_tags(taglist):
""" Format a list of tags into an indented string. """
tags = '\n '.join(str(C(s, fore='cyan')) for s in taglist)
if not tags:
return C('none', fore='red').join('(', ')', style='bright')
return tags
def get_filenames(recurse=False, pathfilter=None):
""" Yield file paths in the current directory.
If recurse is True, walk the current directory yielding paths.
"""
pathfilter = pathfilter or PathFilter.none
cwd = os.getcwd()
debug('\n'.join((
'Getting file names from: {}',
' Filtering: {}'
)).format(cwd, pathfilter))
cnt = 0
if recurse:
try:
for root, dirs, files in os.walk(cwd):
if pathfilter != PathFilter.files:
for dirname in dirs:
cnt += 1
yield os.path.join(root, dirname)
if pathfilter != PathFilter.dirs:
for filename in files:
cnt += 1
yield os.path.join(root, filename)
except EnvironmentError as ex:
print_err('Unable to walk directory: {}'.format(cwd), ex)
else:
filterpath = {
PathFilter.none: lambda s: True,
PathFilter.dirs: os.path.isdir,
PathFilter.files: os.path.isfile
}.get(pathfilter)
try:
for path in os.listdir(cwd):
fullpath = os.path.join(cwd, path)
if filterpath(fullpath):
cnt += 1
yield fullpath
except EnvironmentError as ex:
print_err('Unable to list directory: {}'.format(cwd), ex)
status('\n{}'.format(format_file_cnt('file', cnt)))
def list_action(filenames, value_func_name, format_func, ignore_empty=False):
""" Run an action for the 'list' commands.
Arguments:
filenames : An iterable of valid file names.
values_func_name : Name of Editor method to get values.
format_func : A function to format a filename and values.
See:
format_file_attrs() and format_file_tags()
ignore_empty : Whether to omit file names with no tags set.
Returns the number of errors.
"""
errs = 0
for filename in filenames:
editor = Editor(filename)
value_func = getattr(editor, value_func_name)
try:
values = value_func()
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
if ignore_empty and (not values):
continue
status(format_func(editor.filepath, values))
return errs
def list_attrs(filenames, ignore_empty=False):
""" List raw attributes and values for file names.
Returns the number of errors.
"""
return list_action(
filenames,
'get_attrs',
format_file_attrs,
ignore_empty=ignore_empty)
def list_comments(filenames, ignore_empty=False):
""" List comments for file names.
Returns the number of errors.
"""
return list_action(
filenames,
'get_comment',
format_file_comment,
ignore_empty=ignore_empty)
def list_tags(filenames, ignore_empty=False):
""" List all file tags for file names.
Returns the number of errors.
"""
return list_action(
filenames,
'get_tags',
format_file_tags,
ignore_empty=ignore_empty)
def parse_filenames(filenames, pathfilter=None, nostdin=False):
""" Ensure all file names have an absolute path.
Print any non-existent files.
Returns a set of full paths.
"""
if not filenames:
# No arguments were given, user wants to use the CWD.
return None
pathfilter = pathfilter or PathFilter.none
filterpath = {
PathFilter.none: lambda s: True,
PathFilter.dirs: os.path.isdir,
PathFilter.files: os.path.isfile
}.get(pathfilter)
validnames = set()
for filename in filenames:
if filename == '-':
# Read stdin if not done already.
if nostdin:
continue
stdin_valid = parse_stdin_filenames()
if stdin_valid:
validnames.update(stdin_valid)
continue
# No names were in stdin.
print_err('\nNo valid file names to work with from stdin.')
sys.exit(1)
fullpath = os.path.abspath(filename)
if not os.path.exists(fullpath):
print_err('File does not exist: {}'.format(fullpath))
elif filterpath(fullpath):
validnames.add(fullpath)
else:
raise RuntimeError('Invalid PathFilter enum value!')
debug('User file names: {}, Filter: {}'.format(
len(validnames),
pathfilter))
return validnames
def parse_stdin_filenames():
""" Read file names from stdin. One file name per line. """
if sys.stdin.isatty() and sys.stdout.isatty():
print('\nReading from stdin until end of file (Ctrl + D)...\n')
return parse_filenames(
set(s.strip() for s in sys.stdin.readlines()),
nostdin=True
)
def print_err(msg=None, ex=None):
""" Print an error message.
If an Exception is passed in for `ex`, it's message is also printed.
"""
if msg:
if isinstance(msg, Exception):
# Shortcut use, like print_err(ex=msg).
msglines = str(msg).splitlines()
if len(msglines) > 1:
msg = '\n'.join(msglines[:-1])
ex = msglines[-1]
errmsg = C(msg, fore='red')
sys.stderr.write('{}\n'.format(errmsg))
if ex is not None:
exmsg = C(str(ex), fore='red', style='bright')
sys.stderr.write(' {}\n'.format(exmsg))
sys.stderr.flush()
return None
def remove_comment(filenames):
""" Remove the comment from file names.
Returns the number of errors.
"""
errs = 0
for filename in filenames:
editor = Editor(filename)
try:
editor.clear_comment()
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
# Comment was not available.
status(format_file_name(editor.filepath, label='Cleared comment for'))
return errs
def remove_tag(filenames, tagstr):
""" Remove a tag or tags from file names.
Returns the number of errors.
"""
errs = 0
try:
taglist = Editor.parse_tagstr(tagstr)
except ValueError as ex:
print_err(ex)
return 1
for filename in filenames:
editor = Editor(filename)
try:
finaltags = editor.remove_tags(taglist)
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
status(format_file_tags(filename, finaltags, label='Set tags for'))
return errs
def search(
comments=False, filenames=None, pattern=None,
names_only=False, reverse=False):
""" Run one of the search functions on comments/tags.
If no file names are given, the current directory is used.
If recurse is True, the current directory is walked.
"""
pat = try_repat(pattern)
if pat is None:
return 1
searchargs = {
'names_only': names_only,
'reverse': reverse
}
debug('search args: {!r}'.format(searchargs))
if comments:
return search_comments(filenames, pat, **searchargs)
return search_tags(filenames, pat, **searchargs)
def search_comments(
filenames, repat, names_only=False, reverse=False):
""" Search comments for a pattern.
Returns the number of errors.
"""
debug('Running comment search for: {}'.format(repat.pattern))
found = 0
errs = 0
if reverse:
debug('Using reverse match.')
for filename in filenames:
editor = Editor(filename)
try:
comment = editor.match_comment(repat, reverse=reverse)
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
if comment is not None:
if names_only:
status(format_file_name(editor.filepath))
else:
status(format_file_comment(editor.filepath, comment))
found += 1
if not names_only:
status('\n{}'.format(format_file_cnt('comment', found)))
return errs
def search_tags(
filenames, repat, names_only=False, reverse=False):
""" Search comments for a pattern.
If no file names are given, the current directory is used.
If recurse is True, the current directory is walked.
Returns the number of errors.
"""
debug('Running tag search for: {}'.format(repat.pattern))
found = 0
errs = 0
for filename in filenames:
editor = Editor(filename)
try:
tags = editor.match_tags(repat, reverse=reverse)
except Editor.AttrError as ex:
print_err(ex)
errs += 1
continue
if tags is not None:
found += 1
if names_only:
status(format_file_name(editor.filepath))
else:
status(format_file_tags(editor.filepath, tags))
if not names_only:
status('\n{}'.format(format_file_cnt('tag', found)))
return errs
def set_comment(filenames, comment):
""" Set the comment for file names.
Returns the number of errors.
"""
errs = 0
for filename in filenames:
editor = Editor(filename)
try:
newcomment = editor.set_comment(comment)
except Editor.AttrError as ex:
errs += 1
print_err(ex)
else:
status(
format_file_comment(
editor.filepath,
newcomment,
label='Set comment for'))
return errs
def status(msg, **kwargs):
""" Print a message, unless QUIET is set (with --quiet).
kwargs are for print().
"""
if QUIET:
return None
return print(msg, **kwargs)
def try_repat(s):
""" Try compiling a regex pattern.
On failure, print any errors and return None.
Return the compiled regex pattern on success.
"""
try:
pat = re.compile(s)
except re.error as ex:
print_err('Invalid pattern: {}'.format(s), ex)
return None
return pat
class PathFilter(Enum):
""" File path filter setting. """
none = 0
dirs = 1
files = 2
def __str__(self):
""" Human-friendly string representation for a PathFilter. """
return {
PathFilter.none.value: 'None',
PathFilter.dirs.value: 'Directories',
PathFilter.files.value: 'Files'
}.get(self.value)
@classmethod
def from_argd(cls, argd):
""" Return a PathFilter based on docopt's arg dict. """
if argd['--dirs']:
return cls.dirs
if argd['--files']:
return cls.files
return cls.none
class Editor(object):
""" Holds information and helper methods for a single file and it's
tags/comments.
__init__ possibly raises FileNotFoundError or ValueError (no path).
Tags are comma-separated by default, and encoded using the system's
default encoding. This can be subclassed to handle different formats
by overriding the class methods parse_tagstr() and parse_taglist().
The encoding can be changed by setting Editor.encoding.
If all that is needed is a different separator, then Editor.tag_sep
can be set.
The default attributes are 'user.xdg.tags' and
'user.xdg.comment', but they can also be changed by setting
Editor.attr_tags and Editor.attr_comment.
Finally, if you would like xattr to follow symlinks then set
Editor.follow_symlinks to True.
If you would like AttrError to be raised for missing attributes,
set Editor.errno_nodata to 0, or some other non-existent number in the
errno module.
Instance Attributes:
follow_symlinks : Passed to xattr, whether to follow symlinks.
path : Resolved pathlib.Path().
filepath : File path string (str(self.path)).
tags : List of tags, or [].
comment : String containing the comment, or ''.
"""
# Attributes to use for retrieving tags/comments.
attr_tags = 'user.xdg.tags'
attr_comment = 'user.xdg.comment'
# Encoding to use when setting attribute values.
encoding = sys.getdefaultencoding()
# OSError number for no data available (attribute not available)
errno_nodata = errno.ENODATA
# Overridable separation character for tags when setting/parsing tags.
tag_sep = ','
# Whether xattr should follow symlinks.
follow_symlinks = False
class AttrError(EnvironmentError):
""" Wrapper for EnvironmentError that is raised when getting, setting,
or removing an attribute fails.
Missing attributes, or empty attributes will not cause this.
"""
pass
def __init__(self, path):
""" Resolves a file path and retrieves the tags and comment for it.
Possibly raises FileNotFoundError, or ValueError (for empty path).
"""
self.tags = []
self.comment = ''
try:
self.path = self._get_path(path)
except (FileNotFoundError, ValueError):
raise
else:
# Only possible with a valid (resolved) path.
self.filepath = str(self.path)
self.tags = self.get_tags()
self.comment = self.get_comment()
def _get_path(self, path):
""" Resolve and return `path` if given, otherwise return `self.path`.
If neither are set, a ValueError is raised.
Also possibly raises FileNotFoundError when resolving `path`.
"""
if path:
if isinstance(path, Path):
self.path = path.resolve()
elif isinstance(path, str):
self.path = Path(path).resolve()
if self.path:
return self.path
raise ValueError('No path set for this EditFile instance.')
def add_tag(self, tag):
""" Add a single tag to the tags for this file.
Duplicate tags will not be added.
Returns the new tags as a list.
Raises ValueError if `tag` is falsey.
Possibly raises AttrError.
"""
if not tag:
raise ValueError('Empty tags may not be added: {!r}'.format(tag))
if tag in self.tags:
return self.tags
self.tags.append(tag)
return self.set_tags(self.tags)
def add_tags(self, taglist):
""" Add multiple tags to this file.
Duplicate tags will not be added.
Returns the new tags as a list.
Raises ValueError if taglist is empty.
Possibly raises AttrError.
"""
if not taglist:
raise ValueError(
'Empty tag list may not be added: {!r}'.format(taglist))
self.tags.extend(taglist)
return self.set_tags(self.tags)
def clear_comment(self):
""" Remove the entire comment attribute/value from this file.
Returns True on success.
Possibly raises AttrError.
"""
return self.remove_attr(self.attr_comment)
def clear_tags(self):
""" Remove the entire tags attribute/value from this file.
Returns True on success.
Possibly raises AttrError.
"""
return self.remove_attr(self.attr_tags)
def get_attr(self, attrname):
""" Retrieve a raw attribute value by name. """
try:
tagval = xattr.getxattr(
self.filepath,
attrname,
symlink=self.follow_symlinks)
except EnvironmentError as ex:
if ex.errno == self.errno_nodata:
# No data available.
return None
# Unexpected error.
raise self.AttrError(
'Unable to retrieve \'{}\' for: {}\n{}'.format(
attrname,
self.filepath,
ex))
return tagval.decode()
def get_attrs(self):
""" Return a dict of {attr: value} for all extended attributes for
this file.
Possibly raises AttrError.
"""
try:
attrs = xattr.listxattr(
self.filepath,
symlink=self.follow_symlinks)
except EnvironmentError as ex:
if ex.errno == self.errno_nodata:
return {}
raise self.AttrError(
'Unable to list attributes for file: {}'.format(
self.filepath
),
ex)
return {aname: self.get_attr(aname) for aname in attrs}
def get_comment(self, refresh=False):
""" Return the comment for this file.
If self.comment is already set, return it.
If `refresh` is truthy, or self.comment is not set, retrieve it.
"""
if self.comment and (not refresh):
# Comment was already retrieved, and we're not refreshing data.
return self.comment
comment = self.get_attr(self.attr_comment)
if not comment:
return ''
return comment.strip()
def get_tags(self, refresh=False):
""" Return sorted tags for this file.
If self.tags is already set, return it.
If `refresh` is truthy, or self.tags is not set, retrieve it.
"""
if self.tags and (not refresh):
# Tags were already retrieved, and we are not refreshing the tags.
return self.tags
tagstr = self.get_attr(self.attr_tags)
return self.parse_tagstr(tagstr)
def match_comment(self, repat, reverse=False, ignorecase=False):
""" Return the comment if the regex pattern (`repat`) matches the
comment.
If `reverse` is used, returns the comment if the pattern does not
match.
Returns None on non-matches.
"""
self.comment = self.get_comment()
reflags = re.IGNORECASE if ignorecase else 0
if reverse:
matched = re.search(repat, self.comment, reflags) is None
else:
matched = re.search(repat, self.comment, reflags) is not None
if matched:
return self.comment
return None
def match_tags(self, repat, reverse=False, ignorecase=False):
""" Return the tag list if the regex pattern (`repat`) matches any
tags.
If `reverse` is used, returns the tag list if none of the tags
match.
Returns None on non-matches.
"""
self.tags = self.get_tags()
reflags = re.IGNORECASE if ignorecase else 0
if reverse:
def ismatch(s):
return re.search(repat, s, reflags) is None
# All tags must not match.
boolfilter = all
else:
def ismatch(s):
return re.search(repat, s, reflags) is not None
# Any tag may match.
boolfilter = any
if not self.tags:
# Empty tags. Patterns will test against an empty string.
return [] if ismatch('') else None
if boolfilter(ismatch(s) for s in self.tags):
return self.tags
return None
@classmethod
def parse_taglist(cls, taglist):
""" Parse a tag list into a attribute-friendly string.
Sorts and removes duplicates.
"""
return cls.tag_sep.join(sorted(set(taglist)))
@classmethod
def parse_tagstr(cls, tagstr):
""" Parse a tag str into a sorted list.
`tagstr` should be str or bytes.
"""
if hasattr(tagstr, 'decode'):
tagstr = tagstr.decode()
if not tagstr:
# Empty tag string.
return []
# It is possible that empty tags were set 'tag1,,tag2'...
rawtags = (s.strip() for s in tagstr.split(cls.tag_sep))
# Remove any empty tags.
return sorted(s for s in rawtags if s)
def remove_attr(self, attrname):
""" Remove a raw attribute and value from this file.
Returns True on success.
Possibly raises AttrError.
"""
try:
xattr.removexattr(
self.filepath,
attrname,
symlink=self.follow_symlinks)
except EnvironmentError as ex:
if ex.errno == self.errno_nodata:
# Already removed.
return True