Completed
Push — file-actions ( 3ab188...6cd94b )
by Felipe A.
28s
created

scandir()   A

Complexity

Conditions 4

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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