Completed
Push — 0.5.3 ( 12d39c...2bae48 )
by Felipe A.
01:01
created

create_exclude()   B

Complexity

Conditions 4

Size

Total Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 24
rs 8.6845
cc 4

1 Method

Rating   Name   Duplication   Size   Complexity  
A exclude_fnc() 0 2 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 string
10
import random
11
import datetime
12
import logging
13
14
from flask import current_app, send_from_directory
15
from werkzeug.utils import cached_property
16
17
from . import compat
18
from .compat import range
19
from .stream import TarFileStream
20
21
22
logger = logging.getLogger(__name__)
23
unicode_underscore = '_'.decode('utf-8') if compat.PY_LEGACY else '_'
24
underscore_replace = '%s:underscore' % __name__
25
codecs.register_error(underscore_replace,
26
                      lambda error: (unicode_underscore, error.start + 1)
27
                      )
28
binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
29
standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
30
common_path_separators = '\\/'
31
restricted_chars = '\\/\0'
32
restricted_names = ('.', '..', '::', os.sep)
33
nt_device_names = ('CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1',
34
                   'LPT2', 'LPT3', 'PRN', 'NUL')
35
fs_safe_characters = string.ascii_uppercase + string.digits
36
37
38
class Node(object):
39
    '''
40
    Abstract filesystem node class.
41
42
    This represents an unspecified entity with a filesystem's path suitable for
43
    being inherited by plugins.
44
45
    When inheriting, the following attributes should be overwritten in order
46
    to specify :meth:`from_urlpath` classmethod behavior:
47
48
    * :attr:`generic`, if true, an instance of directory_class or file_class
49
      will be created instead of an instance of this class tself.
50
    * :attr:`directory_class`, class will be used for directory nodes,
51
    * :attr:`file_class`, class will be used for file nodes.
52
    '''
53
    generic = True
54
    directory_class = None  # set later at import time
55
    file_class = None  # set later at import time
56
57
    re_charset = re.compile('; charset=(?P<charset>[^;]+)')
58
    can_download = False
59
    is_root = False
60
61
    @cached_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 check_under_base(self.path, dirbase)
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
        try:
166
            dt = datetime.datetime.fromtimestamp(self.stats.st_mtime)
167
            return dt.strftime('%Y.%m.%d %H:%M:%S')
168
        except OSError:
169
            return None
170
171
    @property
172
    def urlpath(self):
173
        '''
174
        Get the url substring corresponding to this node for those endpoints
175
        accepting a 'path' parameter, suitable for :meth:`from_urlpath`.
176
177
        :returns: relative-url-like for node's path
178
        :rtype: str
179
        '''
180
        return abspath_to_urlpath(self.path, self.app.config['directory_base'])
181
182
    @property
183
    def name(self):
184
        '''
185
        Get the basename portion of node's path.
186
187
        :returns: filename
188
        :rtype: str
189
        '''
190
        return os.path.basename(self.path)
191
192
    @property
193
    def type(self):
194
        '''
195
        Get the mime portion of node's mimetype (without the encoding part).
196
197
        :returns: mimetype
198
        :rtype: str
199
        '''
200
        return self.mimetype.split(";", 1)[0]
201
202
    @property
203
    def category(self):
204
        '''
205
        Get mimetype category (first portion of mimetype before the slash).
206
207
        :returns: mimetype category
208
        :rtype: str
209
210
        As of 2016-11-03's revision of RFC2046 it could be one of the
211
        following:
212
            * application
213
            * audio
214
            * example
215
            * image
216
            * message
217
            * model
218
            * multipart
219
            * text
220
            * video
221
        '''
222
        return self.type.split('/', 1)[0]
223
224
    def __init__(self, path=None, app=None, **defaults):
225
        '''
226
        :param path: local path
227
        :type path: str
228
        :param path: optional app instance
229
        :type path: flask.app
230
        :param **defaults: attributes will be set to object
231
        '''
232
        self.path = compat.fsdecode(path) if path else None
233
        self.app = current_app if app is None else app
234
        self.__dict__.update(defaults)  # only for attr and cached_property
235
236
    def remove(self):
237
        '''
238
        Does nothing except raising if can_remove property returns False.
239
240
        :raises: OutsideRemovableBase if :property:can_remove returns false
241
        '''
242
        if not self.can_remove:
243
            raise OutsideRemovableBase("File outside removable base")
244
245
    @classmethod
246
    def from_urlpath(cls, path, app=None):
247
        '''
248
        Alternative constructor which accepts a path as taken from URL and uses
249
        the given app or the current app config to get the real path.
250
251
        If class has attribute `generic` set to True, `directory_class` or
252
        `file_class` will be used as type.
253
254
        :param path: relative path as from URL
255
        :param app: optional, flask application
256
        :return: file object pointing to path
257
        :rtype: File
258
        '''
259
        app = app or current_app
260
        base = app.config['directory_base']
261
        path = urlpath_to_abspath(path, base)
262
        if not cls.generic:
263
            kls = cls
264
        elif os.path.isdir(path):
265
            kls = cls.directory_class
266
        else:
267
            kls = cls.file_class
268
        return kls(path=path, app=app)
269
270
    @classmethod
271
    def register_file_class(cls, kls):
272
        '''
273
        Convenience method for setting current class file_class property.
274
275
        :param kls: class to set
276
        :type kls: type
277
        :returns: given class (enabling using this as decorator)
278
        :rtype: type
279
        '''
280
        cls.file_class = kls
281
        return kls
282
283
    @classmethod
284
    def register_directory_class(cls, kls):
285
        '''
286
        Convenience method for setting current class directory_class property.
287
288
        :param kls: class to set
289
        :type kls: type
290
        :returns: given class (enabling using this as decorator)
291
        :rtype: type
292
        '''
293
        cls.directory_class = kls
294
        return kls
295
296
297
@Node.register_file_class
298
class File(Node):
299
    '''
300
    Filesystem file class.
301
302
    Some notes:
303
304
    * :attr:`can_download` is fixed to True, so Files can be downloaded
305
      inconditionaly.
306
    * :attr:`can_upload` is fixed to False, so nothing can be uploaded to
307
      file path.
308
    * :attr:`is_directory` is fixed to False, so no further checks are
309
      performed.
310
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
311
      will always return instances of this class.
312
    '''
313
    can_download = True
314
    can_upload = False
315
    is_directory = False
316
    generic = False
317
318
    @cached_property
319
    def widgets(self):
320
        '''
321
        List widgets with filter return True for this file (or without filter).
322
323
        Entry link is prepended.
324
        Download button is prepended if :property:can_download returns true.
325
        Remove button is prepended if :property:can_remove returns true.
326
327
        :returns: list of widgets
328
        :rtype: list of namedtuple instances
329
        '''
330
        widgets = [
331
            self.plugin_manager.create_widget(
332
                'entry-link',
333
                'link',
334
                file=self,
335
                endpoint='open'
336
                )
337
            ]
338
        if self.can_download:
339
            widgets.append(
340
                self.plugin_manager.create_widget(
341
                    'entry-actions',
342
                    'button',
343
                    file=self,
344
                    css='download',
345
                    endpoint='download_file'
346
                    )
347
                )
348
        return widgets + super(File, self).widgets
349
350
    @cached_property
351
    def mimetype(self):
352
        '''
353
        Get full mimetype, with encoding if available.
354
355
        :returns: mimetype
356
        :rtype: str
357
        '''
358
        return self.plugin_manager.get_mimetype(self.path)
359
360
    @cached_property
361
    def is_file(self):
362
        '''
363
        Get if node is file.
364
365
        :returns: True if file, False otherwise
366
        :rtype: bool
367
        '''
368
        return os.path.isfile(self.path)
369
370
    @property
371
    def size(self):
372
        '''
373
        Get human-readable node size in bytes.
374
        If directory, this will corresponds with own inode size.
375
376
        :returns: fuzzy size with unit
377
        :rtype: str
378
        '''
379
        try:
380
            size, unit = fmt_size(
381
                self.stats.st_size,
382
                self.app.config["use_binary_multiples"]
383
                )
384
        except OSError:
385
            return None
386
        if unit == binary_units[0]:
387
            return "%d %s" % (size, unit)
388
        return "%.2f %s" % (size, unit)
389
390
    @property
391
    def encoding(self):
392
        '''
393
        Get encoding part of mimetype, or "default" if not available.
394
395
        :returns: file conding as returned by mimetype function or "default"
396
        :rtype: str
397
        '''
398
        if ";" in self.mimetype:
399
            match = self.re_charset.search(self.mimetype)
400
            gdict = match.groupdict() if match else {}
401
            return gdict.get("charset") or "default"
402
        return "default"
403
404
    def remove(self):
405
        '''
406
        Remove file.
407
        :raises OutsideRemovableBase: when not under removable base directory
408
        '''
409
        super(File, self).remove()
410
        os.unlink(self.path)
411
412
    def download(self):
413
        '''
414
        Get a Flask's send_file Response object pointing to this file.
415
416
        :returns: Response object as returned by flask's send_file
417
        :rtype: flask.Response
418
        '''
419
        directory, name = os.path.split(self.path)
420
        return send_from_directory(directory, name, as_attachment=True)
421
422
423
@Node.register_directory_class
424
class Directory(Node):
425
    '''
426
    Filesystem directory class.
427
428
    Some notes:
429
430
    * :attr:`mimetype` is fixed to 'inode/directory', so mimetype detection
431
      functions won't be called in this case.
432
    * :attr:`is_file` is fixed to False, so no further checks are needed.
433
    * :attr:`size` is fixed to 0 (zero), so stats are not required for this.
434
    * :attr:`encoding` is fixed to 'default'.
435
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
436
      will always return instances of this class.
437
    '''
438
    _listdir_cache = None
439
    mimetype = 'inode/directory'
440
    is_file = False
441
    size = None
442
    encoding = 'default'
443
    generic = False
444
445
    @property
446
    def name(self):
447
        '''
448
        Get the basename portion of directory's path.
449
450
        :returns: filename
451
        :rtype: str
452
        '''
453
        return super(Directory, self).name or self.path
454
455
    @cached_property
456
    def widgets(self):
457
        '''
458
        List widgets with filter return True for this dir (or without filter).
459
460
        Entry link is prepended.
461
        Upload scripts and widget are added if :property:can_upload is true.
462
        Download button is prepended if :property:can_download returns true.
463
        Remove button is prepended if :property:can_remove returns true.
464
465
        :returns: list of widgets
466
        :rtype: list of namedtuple instances
467
        '''
468
        widgets = [
469
            self.plugin_manager.create_widget(
470
                'entry-link',
471
                'link',
472
                file=self,
473
                endpoint='browse'
474
                )
475
            ]
476
        if self.can_upload:
477
            widgets.extend((
478
                self.plugin_manager.create_widget(
479
                    'head',
480
                    'script',
481
                    file=self,
482
                    endpoint='static',
483
                    filename='browse.directory.head.js'
484
                ),
485
                self.plugin_manager.create_widget(
486
                    'scripts',
487
                    'script',
488
                    file=self,
489
                    endpoint='static',
490
                    filename='browse.directory.body.js'
491
                ),
492
                self.plugin_manager.create_widget(
493
                    'header',
494
                    'upload',
495
                    file=self,
496
                    text='Upload',
497
                    endpoint='upload'
498
                    )
499
                ))
500
        if self.can_download:
501
            widgets.append(
502
                self.plugin_manager.create_widget(
503
                    'entry-actions',
504
                    'button',
505
                    file=self,
506
                    css='download',
507
                    endpoint='download_directory'
508
                    )
509
                )
510
        return widgets + super(Directory, self).widgets
511
512
    @cached_property
513
    def is_directory(self):
514
        '''
515
        Get if path points to a real directory.
516
517
        :returns: True if real directory, False otherwise
518
        :rtype: bool
519
        '''
520
        return os.path.isdir(self.path)
521
522
    @cached_property
523
    def is_root(self):
524
        '''
525
        Get if directory is filesystem's root
526
527
        :returns: True if FS root, False otherwise
528
        :rtype: bool
529
        '''
530
        return os.path.dirname(self.path) == self.path
531
532
    @cached_property
533
    def can_download(self):
534
        '''
535
        Get if path is downloadable (if app's `directory_downloadable` config
536
        property is True).
537
538
        :returns: True if downloadable, False otherwise
539
        :rtype: bool
540
        '''
541
        return self.app.config['directory_downloadable']
542
543
    @cached_property
544
    def can_upload(self):
545
        '''
546
        Get if a file can be uploaded to path (if directory path is under app's
547
        `directory_upload` config property).
548
549
        :returns: True if a file can be upload to directory, False otherwise
550
        :rtype: bool
551
        '''
552
        dirbase = self.app.config["directory_upload"]
553
        return dirbase and check_base(self.path, dirbase)
554
555
    @cached_property
556
    def is_empty(self):
557
        '''
558
        Get if directory is empty (based on :meth:`_listdir`).
559
560
        :returns: True if this directory has no entries, False otherwise.
561
        :rtype: bool
562
        '''
563
        if self._listdir_cache is not None:
564
            return bool(self._listdir_cache)
565
        for entry in self._listdir():
566
            return False
567
        return True
568
569
    def remove(self):
570
        '''
571
        Remove directory tree.
572
573
        :raises OutsideRemovableBase: when not under removable base directory
574
        '''
575
        super(Directory, self).remove()
576
        shutil.rmtree(self.path)
577
578
    def download(self):
579
        '''
580
        Get a Flask Response object streaming a tarball of this directory.
581
582
        :returns: Response object
583
        :rtype: flask.Response
584
        '''
585
        return self.app.response_class(
586
            TarFileStream(
587
                self.path,
588
                self.app.config['directory_tar_buffsize'],
589
                create_exclude(self.app),
590
                ),
591
            mimetype="application/octet-stream"
592
            )
593
594
    def contains(self, filename):
595
        '''
596
        Check if directory contains an entry with given filename.
597
598
        :param filename: filename will be check
599
        :type filename: str
600
        :returns: True if exists, False otherwise.
601
        :rtype: bool
602
        '''
603
        return os.path.exists(os.path.join(self.path, filename))
604
605
    def choose_filename(self, filename, attempts=999):
606
        '''
607
        Get a new filename which does not colide with any entry on directory,
608
        based on given filename.
609
610
        :param filename: base filename
611
        :type filename: str
612
        :param attempts: number of attempts, defaults to 999
613
        :type attempts: int
614
        :returns: filename
615
        :rtype: str
616
        '''
617
        new_filename = filename
618
        for attempt in range(2, attempts + 1):
619
            if not self.contains(new_filename):
620
                return new_filename
621
            new_filename = alternative_filename(filename, attempt)
622
        while self.contains(new_filename):
623
            new_filename = alternative_filename(filename)
624
        return new_filename
625
626
    def _listdir(self, precomputed_stats=(os.name == 'nt')):
627
        '''
628
        Iter unsorted entries on this directory.
629
630
        :yields: Directory or File instance for each entry in directory
631
        :ytype: Node
632
        '''
633
        for entry in scandir(self.path, self.app):
634
            kwargs = {'path': entry.path, 'app': self.app, 'parent': self}
635
            try:
636
                if precomputed_stats and not entry.is_symlink():
637
                    kwargs['stats'] = entry.stat()
638
                if entry.is_dir(follow_symlinks=True):
639
                    yield self.directory_class(**kwargs)
640
                    continue
641
                yield self.file_class(**kwargs)
642
            except OSError as e:
643
                logger.exception(e)
644
645
    def listdir(self, sortkey=None, reverse=False):
646
        '''
647
        Get sorted list (by given sortkey and reverse params) of File objects.
648
649
        :return: sorted list of File instances
650
        :rtype: list of File
651
        '''
652
        if self._listdir_cache is None:
653
            if sortkey:
654
                data = sorted(self._listdir(), key=sortkey, reverse=reverse)
655
            elif reverse:
656
                data = list(reversed(self._listdir()))
657
            else:
658
                data = list(self._listdir())
659
            self._listdir_cache = data
660
        return self._listdir_cache
661
662
663
class OutsideDirectoryBase(Exception):
664
    '''
665
    Exception thrown when trying to access to a file outside path defined on
666
    `directory_base` config property.
667
    '''
668
    pass
669
670
671
class OutsideRemovableBase(Exception):
672
    '''
673
    Exception thrown when trying to access to a file outside path defined on
674
    `directory_remove` config property.
675
    '''
676
    pass
677
678
679
def fmt_size(size, binary=True):
680
    '''
681
    Get size and unit.
682
683
    :param size: size in bytes
684
    :type size: int
685
    :param binary: whether use binary or standard units, defaults to True
686
    :type binary: bool
687
    :return: size and unit
688
    :rtype: tuple of int and unit as str
689
    '''
690
    if binary:
691
        fmt_sizes = binary_units
692
        fmt_divider = 1024.
693
    else:
694
        fmt_sizes = standard_units
695
        fmt_divider = 1000.
696
    for fmt in fmt_sizes[:-1]:
697
        if size < 1000:
698
            return (size, fmt)
699
        size /= fmt_divider
700
    return size, fmt_sizes[-1]
701
702
703
def relativize_path(path, base, os_sep=os.sep):
704
    '''
705
    Make absolute path relative to an absolute base.
706
707
    :param path: absolute path
708
    :type path: str
709
    :param base: absolute base path
710
    :type base: str
711
    :param os_sep: path component separator, defaults to current OS separator
712
    :type os_sep: str
713
    :return: relative path
714
    :rtype: str or unicode
715
    :raises OutsideDirectoryBase: if path is not below base
716
    '''
717
    if not check_base(path, base, os_sep):
718
        raise OutsideDirectoryBase("%r is not under %r" % (path, base))
719
    prefix_len = len(base)
720
    if not base.endswith(os_sep):
721
        prefix_len += len(os_sep)
722
    return path[prefix_len:]
723
724
725
def abspath_to_urlpath(path, base, os_sep=os.sep):
726
    '''
727
    Make filesystem absolute path uri relative using given absolute base path.
728
729
    :param path: absolute path
730
    :param base: absolute base path
731
    :param os_sep: path component separator, defaults to current OS separator
732
    :return: relative uri
733
    :rtype: str or unicode
734
    :raises OutsideDirectoryBase: if resulting path is not below base
735
    '''
736
    return relativize_path(path, base, os_sep).replace(os_sep, '/')
737
738
739
def urlpath_to_abspath(path, base, os_sep=os.sep):
740
    '''
741
    Make uri relative path fs absolute using a given absolute base path.
742
743
    :param path: relative path
744
    :param base: absolute base path
745
    :param os_sep: path component separator, defaults to current OS separator
746
    :return: absolute path
747
    :rtype: str or unicode
748
    :raises OutsideDirectoryBase: if resulting path is not below base
749
    '''
750
    prefix = base if base.endswith(os_sep) else base + os_sep
751
    realpath = os.path.abspath(prefix + path.replace('/', os_sep))
752
    if base == realpath or realpath.startswith(prefix):
753
        return realpath
754
    raise OutsideDirectoryBase("%r is not under %r" % (realpath, base))
755
756
757
def generic_filename(path):
758
    '''
759
    Extract filename of given path os-indepently, taking care of known path
760
    separators.
761
762
    :param path: path
763
    :return: filename
764
    :rtype: str or unicode (depending on given path)
765
    '''
766
767
    for sep in common_path_separators:
768
        if sep in path:
769
            _, path = path.rsplit(sep, 1)
770
    return path
771
772
773
def clean_restricted_chars(path, restricted_chars=restricted_chars):
774
    '''
775
    Get path without restricted characters.
776
777
    :param path: path
778
    :return: path without restricted characters
779
    :rtype: str or unicode (depending on given path)
780
    '''
781
    for character in restricted_chars:
782
        path = path.replace(character, '_')
783
    return path
784
785
786
def check_forbidden_filename(filename,
787
                             destiny_os=os.name,
788
                             restricted_names=restricted_names):
789
    '''
790
    Get if given filename is forbidden for current OS or filesystem.
791
792
    :param filename:
793
    :param destiny_os: destination operative system
794
    :param fs_encoding: destination filesystem filename encoding
795
    :return: wether is forbidden on given OS (or filesystem) or not
796
    :rtype: bool
797
    '''
798
    if destiny_os == 'nt':
799
        fpc = filename.split('.', 1)[0].upper()
800
        if fpc in nt_device_names:
801
            return True
802
803
    return filename in restricted_names
804
805
806
def check_base(path, base, os_sep=os.sep):
807
    '''
808
    Check if given absolute path is under or given base.
809
810
    :param path: absolute path
811
    :param base: absolute base path
812
    :return: wether file is under given base or not
813
    :rtype: bool
814
    '''
815
    base = base[:-len(os_sep)] if base.endswith(os_sep) else base
816
    return path == base or check_under_base(path, base, os_sep)
817
818
819
def check_under_base(path, base, os_sep=os.sep):
820
    '''
821
    Check if given absolute path is under given base.
822
823
    :param path: absolute path
824
    :param base: absolute base path
825
    :return: wether file is under given base or not
826
    :rtype: bool
827
    '''
828
    prefix = base if base.endswith(os_sep) else base + os_sep
829
    return path.startswith(prefix)
830
831
832
def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING):
833
    '''
834
    Get rid of parent path components and special filenames.
835
836
    If path is invalid or protected, return empty string.
837
838
    :param path: unsafe path
839
    :type: str
840
    :param destiny_os: destination operative system
841
    :type destiny_os: str
842
    :return: filename or empty string
843
    :rtype: str
844
    '''
845
    path = generic_filename(path)
846
    path = clean_restricted_chars(path)
847
848
    if check_forbidden_filename(path, destiny_os=destiny_os):
849
        return ''
850
851
    if isinstance(path, bytes):
852
        path = path.decode('latin-1', errors=underscore_replace)
853
854
    # Decode and recover from filesystem encoding in order to strip unwanted
855
    # characters out
856
    kwargs = dict(
857
        os_name=destiny_os,
858
        fs_encoding=fs_encoding,
859
        errors=underscore_replace
860
        )
861
    fs_encoded_path = compat.fsencode(path, **kwargs)
862
    fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs)
863
    return fs_decoded_path
864
865
866
def alternative_filename(filename, attempt=None):
867
    '''
868
    Generates an alternative version of given filename.
869
870
    If an number attempt parameter is given, will be used on the alternative
871
    name, a random value will be used otherwise.
872
873
    :param filename: original filename
874
    :param attempt: optional attempt number, defaults to null
875
    :return: new filename
876
    :rtype: str or unicode
877
    '''
878
    filename_parts = filename.rsplit(u'.', 2)
879
    name = filename_parts[0]
880
    ext = ''.join(u'.%s' % ext for ext in filename_parts[1:])
881
    if attempt is None:
882
        choose = random.choice
883
        extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8))
884
    else:
885
        extra = u' (%d)' % attempt
886
    return u'%s%s%s' % (name, extra, ext)
887
888
889
def create_exclude(app, os_sep=os.sep):
890
    '''
891
    Generates an exclude function from given app which relativizes path
892
    to base directory (prefixing sep) before forwarding it to
893
    :attr:`app.config` ``exclude_fnc`` value.
894
895
    This function returns None if exclude_fnc is either None or not defined.
896
897
    :param app: flask application
898
    :type app: flask.Flask or None
899
    :param os_sep: path component separator, defaults to current OS separator
900
    :type os_sep: str
901
    :returns: relative exclude function or None depending on app.config.
902
    :rtype: callable or None
903
    '''
904
    exclude = app.config.get('exclude_fnc') if app else None
905
    if exclude:
906
        def exclude_fnc(path):
907
            return exclude(fmt(rel(path, base, os_sep)))
908
        base = app.config.get('directory_base', '')
909
        rel = relativize_path
910
        fmt = '{}{{}}'.format(os_sep).format
911
        return exclude_fnc
912
    return None
913
914
915
def scandir(path, app, os_sep=os.sep):
916
    '''
917
    Config-aware scandir. Currently, only aware of ``exclude_fnc``.
918
919
    :param path: absolute path
920
    :type path: str
921
    :param app: flask application
922
    :type app: flask.Flask or None
923
    :param os_sep: path component separator, defaults to current OS separator
924
    :type os_sep: str
925
    :returns: filtered scandir entries
926
    :rtype: iterator
927
    '''
928
    exclude = create_exclude(app, os_sep)
929
    if exclude:
930
        return (
931
            item
932
            for item in compat.scandir(path)
933
            if not exclude(item.path)
934
            )
935
    return compat.scandir(path)
936