Completed
Push — dev-4.1 ( 57d16a...5679a9 )
by Felipe A.
02:24 queued 49s
created

TarFileStream.read()   B

Complexity

Conditions 4

Size

Total Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 1
Metric Value
cc 4
dl 0
loc 36
rs 8.5806
c 5
b 0
f 1
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
import os
5
import os.path
6
import re
7
import shutil
8
import codecs
9
import threading
10
import string
11
import tarfile
12
import random
13
import datetime
14
import logging
15
16
from flask import current_app, send_from_directory
17
from werkzeug.utils import cached_property
18
19
from . import compat
20
from .compat import range
21
22
23
logger = logging.getLogger(__name__)
24
unicode_underscore = '_'.decode('utf-8') if compat.PY_LEGACY else '_'
25
underscore_replace = '%s:underscore' % __name__
26
codecs.register_error(underscore_replace,
27
                      lambda error: (unicode_underscore, error.start + 1)
28
                      )
29
binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
30
standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
31
common_path_separators = '\\/'
32
restricted_chars = '\\/\0'
33
restricted_names = ('.', '..', '::', os.sep)
34
nt_device_names = ('CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1',
35
                   'LPT2', 'LPT3', 'PRN', 'NUL')
36
fs_safe_characters = string.ascii_uppercase + string.digits
37
38
39
class Node(object):
40
    '''
41
    Abstract filesystem node class.
42
43
    This represents an unspecified entity with a filesystem's path suitable for
44
    being inherited by plugins.
45
46
    When inheriting, the following attributes should be overwritten in order
47
    to specify :staticmethod:from_urlpath behavior:
48
        :attr:generic if true, an instance of directory_class or file_class
49
                      will be created instead of an instance of this class
50
                      itself.
51
        :attr:directory_class class will be used for directory nodes,
52
        :attr:file_class class will be used for file nodes.
53
    '''
54
    generic = True
55
    directory_class = None  # set later at import time
56
    file_class = None  # set later at import time
57
58
    re_charset = re.compile('; charset=(?P<charset>[^;]+)')
59
    can_download = False
60
61
    @property
62
    def plugin_manager(self):
63
        '''
64
        Get current app's plugin manager.
65
66
        :returns: plugin manager instance
67
        '''
68
        return self.app.extensions['plugin_manager']
69
70
    @cached_property
71
    def widgets(self):
72
        '''
73
        List widgets with filter return True for this node (or without filter).
74
75
        Remove button is prepended if :property:can_remove returns true.
76
77
        :returns: list of widgets
78
        :rtype: list of namedtuple instances
79
        '''
80
        widgets = []
81
        if self.can_remove:
82
            widgets.append(
83
                self.plugin_manager.create_widget(
84
                    'entry-actions',
85
                    'button',
86
                    file=self,
87
                    css='remove',
88
                    endpoint='remove'
89
                    )
90
                )
91
        return widgets + self.plugin_manager.get_widgets(file=self)
92
93
    @cached_property
94
    def link(self):
95
        '''
96
        Get last widget with place "entry-link".
97
98
        :returns: widget on entry-link (ideally a link one)
99
        :rtype: namedtuple instance
100
        '''
101
        link = None
102
        for widget in self.widgets:
103
            if widget.place == 'entry-link':
104
                link = widget
105
        return link
106
107
    @cached_property
108
    def can_remove(self):
109
        '''
110
        Get if current node can be removed based on app config's
111
        directory_remove.
112
113
        :returns: True if current node can be removed, False otherwise.
114
        :rtype: bool
115
        '''
116
        dirbase = self.app.config["directory_remove"]
117
        return dirbase and self.path.startswith(dirbase + os.sep)
118
119
    @cached_property
120
    def stats(self):
121
        '''
122
        Get current stats object as returned by os.stat function.
123
124
        :returns: stats object
125
        :rtype: posix.stat_result or nt.stat_result
126
        '''
127
        return os.stat(self.path)
128
129
    @cached_property
130
    def parent(self):
131
        '''
132
        Get parent node if available based on app config's directory_base.
133
134
        :returns: parent object if available
135
        :rtype: Node instance or None
136
        '''
137
        if self.path == self.app.config['directory_base']:
138
            return None
139
        parent = os.path.dirname(self.path) if self.path else None
140
        return self.directory_class(parent, self.app) if parent else None
141
142
    @cached_property
143
    def ancestors(self):
144
        '''
145
        Get list of ancestors until app config's directory_base is reached.
146
147
        :returns: list of ancestors starting from nearest.
148
        :rtype: list of Node objects
149
        '''
150
        ancestors = []
151
        parent = self.parent
152
        while parent:
153
            ancestors.append(parent)
154
            parent = parent.parent
155
        return ancestors
156
157
    @property
158
    def modified(self):
159
        '''
160
        Get human-readable last modification date-time.
161
162
        :returns: iso9008-like date-time string (without timezone)
163
        :rtype: str
164
        '''
165
        dt = datetime.datetime.fromtimestamp(self.stats.st_mtime)
166
        return dt.strftime('%Y.%m.%d %H:%M:%S')
167
168
    @property
169
    def urlpath(self):
170
        '''
171
        Get the url substring corresponding to this node for those endpoints
172
        accepting a 'path' parameter, suitable for :method:from_urlpath.
173
174
        :returns: relative-url-like for node's path
175
        :rtype: str
176
        '''
177
        return abspath_to_urlpath(self.path, self.app.config['directory_base'])
178
179
    @property
180
    def name(self):
181
        '''
182
        Get the basename portion of node's path.
183
184
        :returns: filename
185
        :rtype: str
186
        '''
187
        return os.path.basename(self.path)
188
189
    @property
190
    def type(self):
191
        '''
192
        Get the mime portion of node's mimetype (without the encoding part).
193
194
        :returns: mimetype
195
        :rtype: str
196
        '''
197
        return self.mimetype.split(";", 1)[0]
198
199
    @property
200
    def category(self):
201
        '''
202
        Get mimetype category (first portion of mimetype before the slash).
203
204
        :returns: mimetype category
205
        :rtype: str
206
207
        As of 2016-11-03's revision of RFC2046 it could be one of the
208
        following:
209
            * application
210
            * audio
211
            * example
212
            * image
213
            * message
214
            * model
215
            * multipart
216
            * text
217
            * video
218
        '''
219
        return self.type.split('/', 1)[0]
220
221
    def __init__(self, path=None, app=None, **defaults):
222
        self.path = compat.fsdecode(path) if path else None
223
        self.app = current_app if app is None else app
224
        self.__dict__.update(defaults)
225
226
    def remove(self):
227
        '''
228
        Does nothing except raising if can_remove property returns False.
229
230
        :raises: OutsideRemovableBase if :property:can_remove returns false
231
        '''
232
        if not self.can_remove:
233
            raise OutsideRemovableBase("File outside removable base")
234
235
    @classmethod
236
    def from_urlpath(cls, path, app=None):
237
        '''
238
        Alternative constructor which accepts a path as taken from URL and uses
239
        the given app or the current app config to get the real path.
240
241
        If class has attribute `generic` set to True, `directory_class` or
242
        `file_class` will be used as type.
243
244
        :param path: relative path as from URL
245
        :param app: optional, flask application
246
        :return: file object pointing to path
247
        :rtype: File
248
        '''
249
        app = app or current_app
250
        base = app.config['directory_base']
251
        path = urlpath_to_abspath(path, base)
252
        if not cls.generic:
253
            kls = cls
254
        elif os.path.isdir(path):
255
            kls = cls.directory_class
256
        else:
257
            kls = cls.file_class
258
        return kls(path=path, app=app)
259
260
    @classmethod
261
    def register_file_class(cls, kls):
262
        '''
263
        Convenience method for setting current class file_class property.
264
265
        :param kls: class to set
266
        :type kls: type
267
        :returns: given class (enabling using this as decorator)
268
        :rtype: type
269
        '''
270
        cls.file_class = kls
271
        return kls
272
273
    @classmethod
274
    def register_directory_class(cls, kls):
275
        '''
276
        Convenience method for setting current class directory_class property.
277
278
        :param kls: class to set
279
        :type kls: type
280
        :returns: given class (enabling using this as decorator)
281
        :rtype: type
282
        '''
283
        cls.directory_class = kls
284
        return kls
285
286
287
@Node.register_file_class
288
class File(Node):
289
    can_download = True
290
    can_upload = False
291
    is_directory = False
292
    generic = False
293
294
    @cached_property
295
    def widgets(self):
296
        '''
297
        List widgets with filter return True for this file (or without filter).
298
299
        Entry link is prepended.
300
        Download button is prepended if :property:can_download returns true.
301
        Remove button is prepended if :property:can_remove returns true.
302
303
        :returns: list of widgets
304
        :rtype: list of namedtuple instances
305
        '''
306
        widgets = [
307
            self.plugin_manager.create_widget(
308
                'entry-link',
309
                'link',
310
                file=self,
311
                endpoint='open'
312
                )
313
            ]
314
        if self.can_download:
315
            widgets.append(
316
                self.plugin_manager.create_widget(
317
                    'entry-actions',
318
                    'button',
319
                    file=self,
320
                    css='download',
321
                    endpoint='download_file'
322
                    )
323
                )
324
        return widgets + super(File, self).widgets
325
326
    @cached_property
327
    def mimetype(self):
328
        '''
329
        Get full mimetype, with encoding if available.
330
331
        :returns: mimetype
332
        :rtype: str
333
        '''
334
        return self.plugin_manager.get_mimetype(self.path)
335
336
    @cached_property
337
    def is_file(self):
338
        '''
339
        Get if node is file.
340
341
        :returns: True if file, False otherwise
342
        :rtype: bool
343
        '''
344
        return os.path.isfile(self.path)
345
346
    @property
347
    def size(self):
348
        '''
349
        Get human-readable node size in bytes.
350
        If directory, this will corresponds with own inode size.
351
352
        :returns: fuzzy size with unit
353
        :rtype: str
354
        '''
355
        size, unit = fmt_size(
356
            self.stats.st_size,
357
            self.app.config["use_binary_multiples"]
358
            )
359
        if unit == binary_units[0]:
360
            return "%d %s" % (size, unit)
361
        return "%.2f %s" % (size, unit)
362
363
    @property
364
    def encoding(self):
365
        '''
366
        Get encoding part of mimetype, or "default" if not available.
367
368
        :returns: file conding as returned by mimetype function or "default"
369
        :rtype: str
370
        '''
371
        if ";" in self.mimetype:
372
            match = self.re_charset.search(self.mimetype)
373
            gdict = match.groupdict() if match else {}
374
            return gdict.get("charset") or "default"
375
        return "default"
376
377
    def remove(self):
378
        '''
379
        Remove file.
380
        :raises OutsideRemovableBase: when not under removable base directory
381
        '''
382
        super(File, self).remove()
383
        os.unlink(self.path)
384
385
    def download(self):
386
        '''
387
        Get a Flask's send_file Response object pointing to this file.
388
389
        :returns: Response object as returned by flask's send_file
390
        :rtype: flask.Response
391
        '''
392
        directory, name = os.path.split(self.path)
393
        return send_from_directory(directory, name, as_attachment=True)
394
395
396
@Node.register_directory_class
397
class Directory(Node):
398
    _listdir_cache = None
399
    mimetype = 'inode/directory'
400
    is_file = False
401
    size = 0
402
    encoding = 'default'
403
    generic = False
404
405
    @cached_property
406
    def widgets(self):
407
        '''
408
        List widgets with filter return True for this dir (or without filter).
409
410
        Entry link is prepended.
411
        Upload scripts and widget are added if :property:can_upload is true.
412
        Download button is prepended if :property:can_download returns true.
413
        Remove button is prepended if :property:can_remove returns true.
414
415
        :returns: list of widgets
416
        :rtype: list of namedtuple instances
417
        '''
418
        widgets = [
419
            self.plugin_manager.create_widget(
420
                'entry-link',
421
                'link',
422
                file=self,
423
                endpoint='browse'
424
                )
425
            ]
426
        if self.can_upload:
427
            widgets.extend((
428
                self.plugin_manager.create_widget(
429
                    'head',
430
                    'script',
431
                    file=self,
432
                    endpoint='static',
433
                    filename='browse.directory.head.js'
434
                ),
435
                self.plugin_manager.create_widget(
436
                    'scripts',
437
                    'script',
438
                    file=self,
439
                    endpoint='static',
440
                    filename='browse.directory.body.js'
441
                ),
442
                self.plugin_manager.create_widget(
443
                    'header',
444
                    'upload',
445
                    file=self,
446
                    text='Upload',
447
                    endpoint='upload'
448
                    )
449
                ))
450
        if self.can_download:
451
            widgets.append(
452
                self.plugin_manager.create_widget(
453
                    'entry-actions',
454
                    'button',
455
                    file=self,
456
                    css='download',
457
                    endpoint='download_directory'
458
                    )
459
                )
460
        return widgets + super(Directory, self).widgets
461
462
    @cached_property
463
    def is_directory(self):
464
        return os.path.isdir(self.path)
465
466
    @cached_property
467
    def can_download(self):
468
        return self.app.config['directory_downloadable']
469
470
    @cached_property
471
    def can_upload(self):
472
        dirbase = self.app.config["directory_upload"]
473
        return dirbase and (
474
            dirbase == self.path or
475
            self.path.startswith(dirbase + os.sep)
476
            )
477
478
    @cached_property
479
    def is_empty(self):
480
        if self._listdir_cache is not None:
481
            return bool(self._listdir_cache)
482
        for entry in self._listdir():
483
            return False
484
        return True
485
486
    def remove(self):
487
        '''
488
        Remove directory tree.
489
490
        :raises OutsideRemovableBase: when not under removable base directory
491
        '''
492
        super(Directory, self).remove()
493
        shutil.rmtree(self.path)
494
495
    def download(self):
496
        '''
497
        Get a Flask Response object streaming a tarball of this directory.
498
499
        :returns: Response object
500
        :rtype: flask.Response
501
        '''
502
        return self.app.response_class(
503
            TarFileStream(
504
                self.path,
505
                self.app.config["directory_tar_buffsize"]
506
                ),
507
            mimetype="application/octet-stream"
508
            )
509
510
    def contains(self, filename):
511
        '''
512
        Check if directory contains an entry with given filename.
513
514
        :param filename: filename will be check
515
        :type filename: str
516
        :returns: True if exists, False otherwise.
517
        :rtype: bool
518
        '''
519
        return os.path.exists(os.path.join(self.path, filename))
520
521
    def choose_filename(self, filename, attempts=999):
522
        '''
523
        Get a new filename which does not colide with any entry on directory,
524
        based on given filename.
525
526
        :param filename: base filename
527
        :type filename: str
528
        :param attempts: number of attempts, defaults to 999
529
        :type attempts: int
530
        :returns: filename
531
        :rtype: str
532
        '''
533
        new_filename = filename
534
        for attempt in range(2, attempts + 1):
535
            if not self.contains(new_filename):
536
                return new_filename
537
            new_filename = alternative_filename(filename, attempt)
538
        while self.contains(new_filename):
539
            new_filename = alternative_filename(filename)
540
        return new_filename
541
542
    def _listdir(self):
543
        '''
544
        Iter unsorted entries on this directory.
545
546
        :yields: Directory or File instance for each entry in directory
547
        :ytype: Node
548
        '''
549
        precomputed_stats = os.name == 'nt'
550
        for entry in compat.scandir(self.path):
551
            kwargs = {'path': entry.path, 'app': self.app, 'parent': self}
552
            if precomputed_stats and not entry.is_symlink():
553
                kwargs['stats'] = entry.stats()
554
            if entry.is_dir(follow_symlinks=True):
555
                yield self.directory_class(**kwargs)
556
                continue
557
            yield self.file_class(**kwargs)
558
559
    def listdir(self, sortkey=None, reverse=False):
560
        '''
561
        Get sorted list (by `is_directory` and `name` properties) of File
562
        objects.
563
564
        :return: sorted list of File instances
565
        :rtype: list of File
566
        '''
567
        if self._listdir_cache is None:
568
            if sortkey:
569
                data = sorted(self._listdir(), key=sortkey, reverse=reverse)
570
            elif reverse:
571
                data = list(reversed(self._listdir()))
572
            else:
573
                data = list(self._listdir())
574
            self._listdir_cache = data
575
        return self._listdir_cache
576
577
578
class TarFileStream(object):
579
    '''
580
    Tarfile which compresses while reading for streaming.
581
582
    Buffsize can be provided, it must be 512 multiple (the tar block size) for
583
    compression.
584
585
    Note on corroutines: this class uses threading by default, but
586
    corroutine-based applications can change this behavior overriding the
587
    :attr:event_class and :attr:thread_class values.
588
    '''
589
    event_class = threading.Event
590
    thread_class = threading.Thread
591
    tarfile_class = tarfile.open
592
593
    def __init__(self, path, buffsize=10240):
594
        '''
595
        Internal tarfile object will be created, and compression will start
596
        on a thread until buffer became full with writes becoming locked until
597
        a read occurs.
598
599
        :param path: local path of directory whose content will be compressed.
600
        :type path: str
601
        :param buffsize: size of internal buffer on bytes, defaults to 10KiB
602
        :type buffsize: int
603
        '''
604
        self.path = path
605
        self.name = os.path.basename(path) + ".tgz"
606
607
        self._finished = 0
608
        self._want = 0
609
        self._data = bytes()
610
        self._add = self.event_class()
611
        self._result = self.event_class()
612
        self._tarfile = self.tarfile_class(  # stream write
613
            fileobj=self,
614
            mode="w|gz",
615
            bufsize=buffsize
616
            )
617
        self._th = self.thread_class(target=self.fill)
618
        self._th.start()
619
620
    def fill(self):
621
        '''
622
        Writes data on internal tarfile instance, which writes to current
623
        object, using :method:write.
624
625
        As this method is blocking, it is used inside a thread.
626
627
        This method is called automatically, on a thread, on initialization,
628
        so there is little need to call it manually.
629
        '''
630
        self._tarfile.add(self.path, "")
631
        self._tarfile.close()  # force stream flush
632
        self._finished += 1
633
        if not self._result.is_set():
634
            self._result.set()
635
636
    def write(self, data):
637
        '''
638
        Write method used by internal tarfile instance to output data.
639
        This method blocks tarfile execution once internal buffer is full.
640
641
        As this method is blocking, it is used inside the same thread of
642
        :method:fill.
643
644
        :param data: bytes to write to internal buffer
645
        :type data: bytes
646
        :returns: number of bytes written
647
        :rtype: int
648
        '''
649
        self._add.wait()
650
        self._data += data
651
        if len(self._data) > self._want:
652
            self._add.clear()
653
            self._result.set()
654
        return len(data)
655
656
    def read(self, want=0):
657
        '''
658
        Read method, gets data from internal buffer while releasing
659
        :method:writelocks when needed.
660
661
        The lock usage means it must ran on a different thread than
662
        :method:fill, ie. the main thread, otherwise will deadlock.
663
664
        The combination of both write and this method running on different
665
        threads makes tarfile being streamed on-the-fly, with data chunks being
666
        processed and retrieved on demand.
667
668
        :param want: number bytes to read, defaults to 0 (all available)
669
        :type want: int
670
        :returns: tarfile data as bytes
671
        :rtype: bytes
672
        '''
673
        if self._finished:
674
            if self._finished == 1:
675
                self._finished += 1
676
                return ""
677
            return EOFError("EOF reached")
678
679
        # Thread communication
680
        self._want = want
681
        self._add.set()
682
        self._result.wait()
683
        self._result.clear()
684
685
        if want:
686
            data = self._data[:want]
687
            self._data = self._data[want:]
688
        else:
689
            data = self._data
690
            self._data = bytes()
691
        return data
692
693
    def __iter__(self):
694
        '''
695
        Iterate through tarfile result chunks.
696
697
        Similarly to :method:read, this methos must ran on a different thread
698
        than :method:write calls.
699
700
        :yields: data chunks as taken from :method:read.
701
        :ytype: bytes
702
        '''
703
        data = self.read()
704
        while data:
705
            yield data
706
            data = self.read()
707
708
709
class OutsideDirectoryBase(Exception):
710
    pass
711
712
713
class OutsideRemovableBase(Exception):
714
    pass
715
716
717
def fmt_size(size, binary=True):
718
    '''
719
    Get size and unit.
720
721
    :param size: size in bytes
722
    :param binary: whether use binary or standard units, defaults to True
723
    :return: size and unit
724
    :rtype: tuple of int and unit as str
725
    '''
726
    if binary:
727
        fmt_sizes = binary_units
728
        fmt_divider = 1024.
729
    else:
730
        fmt_sizes = standard_units
731
        fmt_divider = 1000.
732
    for fmt in fmt_sizes[:-1]:
733
        if size < 1000:
734
            return (size, fmt)
735
        size /= fmt_divider
736
    return size, fmt_sizes[-1]
737
738
739
def relativize_path(path, base, os_sep=os.sep):
740
    '''
741
    Make absolute path relative to an absolute base.
742
743
    :param path: absolute path
744
    :param base: absolute base path
745
    :param os_sep: path component separator, defaults to current OS separator
746
    :return: relative path
747
    :rtype: str or unicode
748
    :raises OutsideDirectoryBase: if path is not below base
749
    '''
750
    if not check_under_base(path, base, os_sep):
751
        raise OutsideDirectoryBase("%r is not under %r" % (path, base))
752
    prefix_len = len(base)
753
    if not base.endswith(os_sep):
754
        prefix_len += len(os_sep)
755
    return path[prefix_len:]
756
757
758
def abspath_to_urlpath(path, base, os_sep=os.sep):
759
    '''
760
    Make filesystem absolute path uri relative using given absolute base path.
761
762
    :param path: absolute path
763
    :param base: absolute base path
764
    :param os_sep: path component separator, defaults to current OS separator
765
    :return: relative uri
766
    :rtype: str or unicode
767
    :raises OutsideDirectoryBase: if resulting path is not below base
768
    '''
769
    return relativize_path(path, base, os_sep).replace(os_sep, '/')
770
771
772
def urlpath_to_abspath(path, base, os_sep=os.sep):
773
    '''
774
    Make uri relative path fs absolute using a given absolute base path.
775
776
    :param path: relative path
777
    :param base: absolute base path
778
    :param os_sep: path component separator, defaults to current OS separator
779
    :return: absolute path
780
    :rtype: str or unicode
781
    :raises OutsideDirectoryBase: if resulting path is not below base
782
    '''
783
    prefix = base if base.endswith(os_sep) else base + os_sep
784
    realpath = os.path.abspath(prefix + path.replace('/', os_sep))
785
    if base == realpath or realpath.startswith(prefix):
786
        return realpath
787
    raise OutsideDirectoryBase("%r is not under %r" % (realpath, base))
788
789
790
def generic_filename(path):
791
    '''
792
    Extract filename of given path os-indepently, taking care of known path
793
    separators.
794
795
    :param path: path
796
    :return: filename
797
    :rtype: str or unicode (depending on given path)
798
    '''
799
800
    for sep in common_path_separators:
801
        if sep in path:
802
            _, path = path.rsplit(sep, 1)
803
    return path
804
805
806
def clean_restricted_chars(path, restricted_chars=restricted_chars):
807
    '''
808
    Get path without restricted characters.
809
810
    :param path: path
811
    :return: path without restricted characters
812
    :rtype: str or unicode (depending on given path)
813
    '''
814
    for character in restricted_chars:
815
        path = path.replace(character, '_')
816
    return path
817
818
819
def check_forbidden_filename(filename,
820
                             destiny_os=os.name,
821
                             restricted_names=restricted_names):
822
    '''
823
    Get if given filename is forbidden for current OS or filesystem.
824
825
    :param filename:
826
    :param destiny_os: destination operative system
827
    :param fs_encoding: destination filesystem filename encoding
828
    :return: wether is forbidden on given OS (or filesystem) or not
829
    :rtype: bool
830
    '''
831
    if destiny_os == 'nt':
832
        fpc = filename.split('.', 1)[0].upper()
833
        if fpc in nt_device_names:
834
            return True
835
836
    return filename in restricted_names
837
838
839
def check_under_base(path, base, os_sep=os.sep):
840
    '''
841
    Check if given absolute path is under given base.
842
843
    :param path: absolute path
844
    :param base: absolute base path
845
    :return: wether file is under given base or not
846
    :rtype: bool
847
    '''
848
    prefix = base if base.endswith(os_sep) else base + os_sep
849
    return path == base or path.startswith(prefix)
850
851
852
def secure_filename(path, destiny_os=os.name, fs_encoding=compat.fs_encoding):
853
    '''
854
    Get rid of parent path components and special filenames.
855
856
    If path is invalid or protected, return empty string.
857
858
    :param path: unsafe path
859
    :type: str
860
    :param destiny_os: destination operative system
861
    :type destiny_os: str
862
    :return: filename or empty string
863
    :rtype: str
864
    '''
865
    path = generic_filename(path)
866
    path = clean_restricted_chars(path)
867
868
    if check_forbidden_filename(path, destiny_os=destiny_os):
869
        return ''
870
871
    if isinstance(path, bytes):
872
        path = path.decode('latin-1', errors=underscore_replace)
873
874
    # Decode and recover from filesystem encoding in order to strip unwanted
875
    # characters out
876
    kwargs = dict(
877
        os_name=destiny_os,
878
        fs_encoding=fs_encoding,
879
        errors=underscore_replace
880
        )
881
    fs_encoded_path = compat.fsencode(path, **kwargs)
882
    fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs)
883
    return fs_decoded_path
884
885
886
def alternative_filename(filename, attempt=None):
887
    '''
888
    Generates an alternative version of given filename.
889
890
    If an number attempt parameter is given, will be used on the alternative
891
    name, a random value will be used otherwise.
892
893
    :param filename: original filename
894
    :param attempt: optional attempt number, defaults to null
895
    :return: new filename
896
    :rtype: str or unicode
897
    '''
898
    filename_parts = filename.rsplit(u'.', 2)
899
    name = filename_parts[0]
900
    ext = ''.join(u'.%s' % ext for ext in filename_parts[1:])
901
    if attempt is None:
902
        choose = random.choice
903
        extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8))
904
    else:
905
        extra = u' (%d)' % attempt
906
    return u'%s%s%s' % (name, extra, ext)
907