Completed
Push — 0.5.3 ( 4431d3...f700f6 )
by Felipe A.
01:02
created

Directory.is_root()   A

Complexity

Conditions 1

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
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 :meth:`from_urlpath` classmethod behavior:
48
49
    * :attr:`generic`, if true, an instance of directory_class or file_class
50
      will be created instead of an instance of this class tself.
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
    is_root = False
61
62
    @cached_property
63
    def plugin_manager(self):
64
        '''
65
        Get current app's plugin manager.
66
67
        :returns: plugin manager instance
68
        '''
69
        return self.app.extensions['plugin_manager']
70
71
    @cached_property
72
    def widgets(self):
73
        '''
74
        List widgets with filter return True for this node (or without filter).
75
76
        Remove button is prepended if :property:can_remove returns true.
77
78
        :returns: list of widgets
79
        :rtype: list of namedtuple instances
80
        '''
81
        widgets = []
82
        if self.can_remove:
83
            widgets.append(
84
                self.plugin_manager.create_widget(
85
                    'entry-actions',
86
                    'button',
87
                    file=self,
88
                    css='remove',
89
                    endpoint='remove'
90
                    )
91
                )
92
        return widgets + self.plugin_manager.get_widgets(file=self)
93
94
    @cached_property
95
    def link(self):
96
        '''
97
        Get last widget with place "entry-link".
98
99
        :returns: widget on entry-link (ideally a link one)
100
        :rtype: namedtuple instance
101
        '''
102
        link = None
103
        for widget in self.widgets:
104
            if widget.place == 'entry-link':
105
                link = widget
106
        return link
107
108
    @cached_property
109
    def can_remove(self):
110
        '''
111
        Get if current node can be removed based on app config's
112
        directory_remove.
113
114
        :returns: True if current node can be removed, False otherwise.
115
        :rtype: bool
116
        '''
117
        dirbase = self.app.config["directory_remove"]
118
        return dirbase and check_under_base(self.path, dirbase)
119
120
    @cached_property
121
    def stats(self):
122
        '''
123
        Get current stats object as returned by os.stat function.
124
125
        :returns: stats object
126
        :rtype: posix.stat_result or nt.stat_result
127
        '''
128
        return os.stat(self.path)
129
130
    @cached_property
131
    def parent(self):
132
        '''
133
        Get parent node if available based on app config's directory_base.
134
135
        :returns: parent object if available
136
        :rtype: Node instance or None
137
        '''
138
        if self.path == self.app.config['directory_base']:
139
            return None
140
        parent = os.path.dirname(self.path) if self.path else None
141
        return self.directory_class(parent, self.app) if parent else None
142
143
    @cached_property
144
    def ancestors(self):
145
        '''
146
        Get list of ancestors until app config's directory_base is reached.
147
148
        :returns: list of ancestors starting from nearest.
149
        :rtype: list of Node objects
150
        '''
151
        ancestors = []
152
        parent = self.parent
153
        while parent:
154
            ancestors.append(parent)
155
            parent = parent.parent
156
        return ancestors
157
158
    @property
159
    def modified(self):
160
        '''
161
        Get human-readable last modification date-time.
162
163
        :returns: iso9008-like date-time string (without timezone)
164
        :rtype: str
165
        '''
166
        try:
167
            dt = datetime.datetime.fromtimestamp(self.stats.st_mtime)
168
            return dt.strftime('%Y.%m.%d %H:%M:%S')
169
        except OSError:
170
            return None
171
172
    @property
173
    def urlpath(self):
174
        '''
175
        Get the url substring corresponding to this node for those endpoints
176
        accepting a 'path' parameter, suitable for :meth:`from_urlpath`.
177
178
        :returns: relative-url-like for node's path
179
        :rtype: str
180
        '''
181
        return abspath_to_urlpath(self.path, self.app.config['directory_base'])
182
183
    @property
184
    def name(self):
185
        '''
186
        Get the basename portion of node's path.
187
188
        :returns: filename
189
        :rtype: str
190
        '''
191
        return os.path.basename(self.path)
192
193
    @property
194
    def type(self):
195
        '''
196
        Get the mime portion of node's mimetype (without the encoding part).
197
198
        :returns: mimetype
199
        :rtype: str
200
        '''
201
        return self.mimetype.split(";", 1)[0]
202
203
    @property
204
    def category(self):
205
        '''
206
        Get mimetype category (first portion of mimetype before the slash).
207
208
        :returns: mimetype category
209
        :rtype: str
210
211
        As of 2016-11-03's revision of RFC2046 it could be one of the
212
        following:
213
            * application
214
            * audio
215
            * example
216
            * image
217
            * message
218
            * model
219
            * multipart
220
            * text
221
            * video
222
        '''
223
        return self.type.split('/', 1)[0]
224
225
    def __init__(self, path=None, app=None, **defaults):
226
        '''
227
        :param path: local path
228
        :type path: str
229
        :param path: optional app instance
230
        :type path: flask.app
231
        :param **defaults: attributes will be set to object
232
        '''
233
        self.path = compat.fsdecode(path) if path else None
234
        self.app = current_app if app is None else app
235
        self.__dict__.update(defaults)  # only for attr and cached_property
236
237
    def remove(self):
238
        '''
239
        Does nothing except raising if can_remove property returns False.
240
241
        :raises: OutsideRemovableBase if :property:can_remove returns false
242
        '''
243
        if not self.can_remove:
244
            raise OutsideRemovableBase("File outside removable base")
245
246
    @classmethod
247
    def from_urlpath(cls, path, app=None):
248
        '''
249
        Alternative constructor which accepts a path as taken from URL and uses
250
        the given app or the current app config to get the real path.
251
252
        If class has attribute `generic` set to True, `directory_class` or
253
        `file_class` will be used as type.
254
255
        :param path: relative path as from URL
256
        :param app: optional, flask application
257
        :return: file object pointing to path
258
        :rtype: File
259
        '''
260
        app = app or current_app
261
        base = app.config['directory_base']
262
        path = urlpath_to_abspath(path, base)
263
        if not cls.generic:
264
            kls = cls
265
        elif os.path.isdir(path):
266
            kls = cls.directory_class
267
        else:
268
            kls = cls.file_class
269
        return kls(path=path, app=app)
270
271
    @classmethod
272
    def register_file_class(cls, kls):
273
        '''
274
        Convenience method for setting current class file_class property.
275
276
        :param kls: class to set
277
        :type kls: type
278
        :returns: given class (enabling using this as decorator)
279
        :rtype: type
280
        '''
281
        cls.file_class = kls
282
        return kls
283
284
    @classmethod
285
    def register_directory_class(cls, kls):
286
        '''
287
        Convenience method for setting current class directory_class property.
288
289
        :param kls: class to set
290
        :type kls: type
291
        :returns: given class (enabling using this as decorator)
292
        :rtype: type
293
        '''
294
        cls.directory_class = kls
295
        return kls
296
297
298
@Node.register_file_class
299
class File(Node):
300
    '''
301
    Filesystem file class.
302
303
    Some notes:
304
305
    * :attr:`can_download` is fixed to True, so Files can be downloaded
306
      inconditionaly.
307
    * :attr:`can_upload` is fixed to False, so nothing can be uploaded to
308
      file path.
309
    * :attr:`is_directory` is fixed to False, so no further checks are
310
      performed.
311
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
312
      will always return instances of this class.
313
    '''
314
    can_download = True
315
    can_upload = False
316
    is_directory = False
317
    generic = False
318
319
    @cached_property
320
    def widgets(self):
321
        '''
322
        List widgets with filter return True for this file (or without filter).
323
324
        Entry link is prepended.
325
        Download button is prepended if :property:can_download returns true.
326
        Remove button is prepended if :property:can_remove returns true.
327
328
        :returns: list of widgets
329
        :rtype: list of namedtuple instances
330
        '''
331
        widgets = [
332
            self.plugin_manager.create_widget(
333
                'entry-link',
334
                'link',
335
                file=self,
336
                endpoint='open'
337
                )
338
            ]
339
        if self.can_download:
340
            widgets.append(
341
                self.plugin_manager.create_widget(
342
                    'entry-actions',
343
                    'button',
344
                    file=self,
345
                    css='download',
346
                    endpoint='download_file'
347
                    )
348
                )
349
        return widgets + super(File, self).widgets
350
351
    @cached_property
352
    def mimetype(self):
353
        '''
354
        Get full mimetype, with encoding if available.
355
356
        :returns: mimetype
357
        :rtype: str
358
        '''
359
        return self.plugin_manager.get_mimetype(self.path)
360
361
    @cached_property
362
    def is_file(self):
363
        '''
364
        Get if node is file.
365
366
        :returns: True if file, False otherwise
367
        :rtype: bool
368
        '''
369
        return os.path.isfile(self.path)
370
371
    @property
372
    def size(self):
373
        '''
374
        Get human-readable node size in bytes.
375
        If directory, this will corresponds with own inode size.
376
377
        :returns: fuzzy size with unit
378
        :rtype: str
379
        '''
380
        try:
381
            size, unit = fmt_size(
382
                self.stats.st_size,
383
                self.app.config["use_binary_multiples"]
384
                )
385
        except OSError:
386
            return None
387
        if unit == binary_units[0]:
388
            return "%d %s" % (size, unit)
389
        return "%.2f %s" % (size, unit)
390
391
    @property
392
    def encoding(self):
393
        '''
394
        Get encoding part of mimetype, or "default" if not available.
395
396
        :returns: file conding as returned by mimetype function or "default"
397
        :rtype: str
398
        '''
399
        if ";" in self.mimetype:
400
            match = self.re_charset.search(self.mimetype)
401
            gdict = match.groupdict() if match else {}
402
            return gdict.get("charset") or "default"
403
        return "default"
404
405
    def remove(self):
406
        '''
407
        Remove file.
408
        :raises OutsideRemovableBase: when not under removable base directory
409
        '''
410
        super(File, self).remove()
411
        os.unlink(self.path)
412
413
    def download(self):
414
        '''
415
        Get a Flask's send_file Response object pointing to this file.
416
417
        :returns: Response object as returned by flask's send_file
418
        :rtype: flask.Response
419
        '''
420
        directory, name = os.path.split(self.path)
421
        return send_from_directory(directory, name, as_attachment=True)
422
423
424
@Node.register_directory_class
425
class Directory(Node):
426
    '''
427
    Filesystem directory class.
428
429
    Some notes:
430
431
    * :attr:`mimetype` is fixed to 'inode/directory', so mimetype detection
432
      functions won't be called in this case.
433
    * :attr:`is_file` is fixed to False, so no further checks are needed.
434
    * :attr:`size` is fixed to 0 (zero), so stats are not required for this.
435
    * :attr:`encoding` is fixed to 'default'.
436
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
437
      will always return instances of this class.
438
    '''
439
    _listdir_cache = None
440
    mimetype = 'inode/directory'
441
    is_file = False
442
    size = None
443
    encoding = 'default'
444
    generic = False
445
446
    @property
447
    def name(self):
448
        '''
449
        Get the basename portion of directory's path.
450
451
        :returns: filename
452
        :rtype: str
453
        '''
454
        return super(Directory, self).name or self.path
455
456
    @cached_property
457
    def widgets(self):
458
        '''
459
        List widgets with filter return True for this dir (or without filter).
460
461
        Entry link is prepended.
462
        Upload scripts and widget are added if :property:can_upload is true.
463
        Download button is prepended if :property:can_download returns true.
464
        Remove button is prepended if :property:can_remove returns true.
465
466
        :returns: list of widgets
467
        :rtype: list of namedtuple instances
468
        '''
469
        widgets = [
470
            self.plugin_manager.create_widget(
471
                'entry-link',
472
                'link',
473
                file=self,
474
                endpoint='browse'
475
                )
476
            ]
477
        if self.can_upload:
478
            widgets.extend((
479
                self.plugin_manager.create_widget(
480
                    'head',
481
                    'script',
482
                    file=self,
483
                    endpoint='static',
484
                    filename='browse.directory.head.js'
485
                ),
486
                self.plugin_manager.create_widget(
487
                    'scripts',
488
                    'script',
489
                    file=self,
490
                    endpoint='static',
491
                    filename='browse.directory.body.js'
492
                ),
493
                self.plugin_manager.create_widget(
494
                    'header',
495
                    'upload',
496
                    file=self,
497
                    text='Upload',
498
                    endpoint='upload'
499
                    )
500
                ))
501
        if self.can_download:
502
            widgets.append(
503
                self.plugin_manager.create_widget(
504
                    'entry-actions',
505
                    'button',
506
                    file=self,
507
                    css='download',
508
                    endpoint='download_directory'
509
                    )
510
                )
511
        return widgets + super(Directory, self).widgets
512
513
    @cached_property
514
    def is_directory(self):
515
        '''
516
        Get if path points to a real directory.
517
518
        :returns: True if real directory, False otherwise
519
        :rtype: bool
520
        '''
521
        return os.path.isdir(self.path)
522
523
    @cached_property
524
    def is_root(self):
525
        '''
526
        Get if directory is filesystem's root
527
528
        :returns: True if FS root, False otherwise
529
        :rtype: bool
530
        '''
531
        return os.path.dirname(self.path) == self.path
532
533
    @cached_property
534
    def can_download(self):
535
        '''
536
        Get if path is downloadable (if app's `directory_downloadable` config
537
        property is True).
538
539
        :returns: True if downloadable, False otherwise
540
        :rtype: bool
541
        '''
542
        return self.app.config['directory_downloadable']
543
544
    @cached_property
545
    def can_upload(self):
546
        '''
547
        Get if a file can be uploaded to path (if directory path is under app's
548
        `directory_upload` config property).
549
550
        :returns: True if a file can be upload to directory, False otherwise
551
        :rtype: bool
552
        '''
553
        dirbase = self.app.config["directory_upload"]
554
        return dirbase and check_base(self.path, dirbase)
555
556
    @cached_property
557
    def is_empty(self):
558
        '''
559
        Get if directory is empty (based on :meth:`_listdir`).
560
561
        :returns: True if this directory has no entries, False otherwise.
562
        :rtype: bool
563
        '''
564
        if self._listdir_cache is not None:
565
            return bool(self._listdir_cache)
566
        for entry in self._listdir():
567
            return False
568
        return True
569
570
    def remove(self):
571
        '''
572
        Remove directory tree.
573
574
        :raises OutsideRemovableBase: when not under removable base directory
575
        '''
576
        super(Directory, self).remove()
577
        shutil.rmtree(self.path)
578
579
    def download(self):
580
        '''
581
        Get a Flask Response object streaming a tarball of this directory.
582
583
        :returns: Response object
584
        :rtype: flask.Response
585
        '''
586
        return self.app.response_class(
587
            TarFileStream(
588
                self.path,
589
                self.app.config["directory_tar_buffsize"]
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 compat.scandir(self.path):
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 TarFileStream(object):
664
    '''
665
    Tarfile which compresses while reading for streaming.
666
667
    Buffsize can be provided, it must be 512 multiple (the tar block size) for
668
    compression.
669
670
    Note on corroutines: this class uses threading by default, but
671
    corroutine-based applications can change this behavior overriding the
672
    :attr:`event_class` and :attr:`thread_class` values.
673
    '''
674
    event_class = threading.Event
675
    thread_class = threading.Thread
676
    tarfile_class = tarfile.open
677
678
    def __init__(self, path, buffsize=10240):
679
        '''
680
        Internal tarfile object will be created, and compression will start
681
        on a thread until buffer became full with writes becoming locked until
682
        a read occurs.
683
684
        :param path: local path of directory whose content will be compressed.
685
        :type path: str
686
        :param buffsize: size of internal buffer on bytes, defaults to 10KiB
687
        :type buffsize: int
688
        '''
689
        self.path = path
690
        self.name = os.path.basename(path) + ".tgz"
691
692
        self._finished = 0
693
        self._want = 0
694
        self._data = bytes()
695
        self._add = self.event_class()
696
        self._result = self.event_class()
697
        self._tarfile = self.tarfile_class(  # stream write
698
            fileobj=self,
699
            mode="w|gz",
700
            bufsize=buffsize
701
            )
702
        self._th = self.thread_class(target=self.fill)
703
        self._th.start()
704
705
    def fill(self):
706
        '''
707
        Writes data on internal tarfile instance, which writes to current
708
        object, using :meth:`write`.
709
710
        As this method is blocking, it is used inside a thread.
711
712
        This method is called automatically, on a thread, on initialization,
713
        so there is little need to call it manually.
714
        '''
715
        self._tarfile.add(self.path, "")
716
        self._tarfile.close()  # force stream flush
717
        self._finished += 1
718
        if not self._result.is_set():
719
            self._result.set()
720
721
    def write(self, data):
722
        '''
723
        Write method used by internal tarfile instance to output data.
724
        This method blocks tarfile execution once internal buffer is full.
725
726
        As this method is blocking, it is used inside the same thread of
727
        :meth:`fill`.
728
729
        :param data: bytes to write to internal buffer
730
        :type data: bytes
731
        :returns: number of bytes written
732
        :rtype: int
733
        '''
734
        self._add.wait()
735
        self._data += data
736
        if len(self._data) > self._want:
737
            self._add.clear()
738
            self._result.set()
739
        return len(data)
740
741
    def read(self, want=0):
742
        '''
743
        Read method, gets data from internal buffer while releasing
744
        :meth:`write` locks when needed.
745
746
        The lock usage means it must ran on a different thread than
747
        :meth:`fill`, ie. the main thread, otherwise will deadlock.
748
749
        The combination of both write and this method running on different
750
        threads makes tarfile being streamed on-the-fly, with data chunks being
751
        processed and retrieved on demand.
752
753
        :param want: number bytes to read, defaults to 0 (all available)
754
        :type want: int
755
        :returns: tarfile data as bytes
756
        :rtype: bytes
757
        '''
758
        if self._finished:
759
            if self._finished == 1:
760
                self._finished += 1
761
                return ""
762
            return EOFError("EOF reached")
763
764
        # Thread communication
765
        self._want = want
766
        self._add.set()
767
        self._result.wait()
768
        self._result.clear()
769
770
        if want:
771
            data = self._data[:want]
772
            self._data = self._data[want:]
773
        else:
774
            data = self._data
775
            self._data = bytes()
776
        return data
777
778
    def __iter__(self):
779
        '''
780
        Iterate through tarfile result chunks.
781
782
        Similarly to :meth:`read`, this methos must ran on a different thread
783
        than :meth:`write` calls.
784
785
        :yields: data chunks as taken from :meth:`read`.
786
        :ytype: bytes
787
        '''
788
        data = self.read()
789
        while data:
790
            yield data
791
            data = self.read()
792
793
794
class OutsideDirectoryBase(Exception):
795
    '''
796
    Exception thrown when trying to access to a file outside path defined on
797
    `directory_base` config property.
798
    '''
799
    pass
800
801
802
class OutsideRemovableBase(Exception):
803
    '''
804
    Exception thrown when trying to access to a file outside path defined on
805
    `directory_remove` config property.
806
    '''
807
    pass
808
809
810
def fmt_size(size, binary=True):
811
    '''
812
    Get size and unit.
813
814
    :param size: size in bytes
815
    :param binary: whether use binary or standard units, defaults to True
816
    :return: size and unit
817
    :rtype: tuple of int and unit as str
818
    '''
819
    if binary:
820
        fmt_sizes = binary_units
821
        fmt_divider = 1024.
822
    else:
823
        fmt_sizes = standard_units
824
        fmt_divider = 1000.
825
    for fmt in fmt_sizes[:-1]:
826
        if size < 1000:
827
            return (size, fmt)
828
        size /= fmt_divider
829
    return size, fmt_sizes[-1]
830
831
832
def relativize_path(path, base, os_sep=os.sep):
833
    '''
834
    Make absolute path relative to an absolute base.
835
836
    :param path: absolute path
837
    :param base: absolute base path
838
    :param os_sep: path component separator, defaults to current OS separator
839
    :return: relative path
840
    :rtype: str or unicode
841
    :raises OutsideDirectoryBase: if path is not below base
842
    '''
843
    if not check_base(path, base, os_sep):
844
        raise OutsideDirectoryBase("%r is not under %r" % (path, base))
845
    prefix_len = len(base)
846
    if not base.endswith(os_sep):
847
        prefix_len += len(os_sep)
848
    return path[prefix_len:]
849
850
851
def abspath_to_urlpath(path, base, os_sep=os.sep):
852
    '''
853
    Make filesystem absolute path uri relative using given absolute base path.
854
855
    :param path: absolute path
856
    :param base: absolute base path
857
    :param os_sep: path component separator, defaults to current OS separator
858
    :return: relative uri
859
    :rtype: str or unicode
860
    :raises OutsideDirectoryBase: if resulting path is not below base
861
    '''
862
    return relativize_path(path, base, os_sep).replace(os_sep, '/')
863
864
865
def urlpath_to_abspath(path, base, os_sep=os.sep):
866
    '''
867
    Make uri relative path fs absolute using a given absolute base path.
868
869
    :param path: relative path
870
    :param base: absolute base path
871
    :param os_sep: path component separator, defaults to current OS separator
872
    :return: absolute path
873
    :rtype: str or unicode
874
    :raises OutsideDirectoryBase: if resulting path is not below base
875
    '''
876
    prefix = base if base.endswith(os_sep) else base + os_sep
877
    realpath = os.path.abspath(prefix + path.replace('/', os_sep))
878
    if base == realpath or realpath.startswith(prefix):
879
        return realpath
880
    raise OutsideDirectoryBase("%r is not under %r" % (realpath, base))
881
882
883
def generic_filename(path):
884
    '''
885
    Extract filename of given path os-indepently, taking care of known path
886
    separators.
887
888
    :param path: path
889
    :return: filename
890
    :rtype: str or unicode (depending on given path)
891
    '''
892
893
    for sep in common_path_separators:
894
        if sep in path:
895
            _, path = path.rsplit(sep, 1)
896
    return path
897
898
899
def clean_restricted_chars(path, restricted_chars=restricted_chars):
900
    '''
901
    Get path without restricted characters.
902
903
    :param path: path
904
    :return: path without restricted characters
905
    :rtype: str or unicode (depending on given path)
906
    '''
907
    for character in restricted_chars:
908
        path = path.replace(character, '_')
909
    return path
910
911
912
def check_forbidden_filename(filename,
913
                             destiny_os=os.name,
914
                             restricted_names=restricted_names):
915
    '''
916
    Get if given filename is forbidden for current OS or filesystem.
917
918
    :param filename:
919
    :param destiny_os: destination operative system
920
    :param fs_encoding: destination filesystem filename encoding
921
    :return: wether is forbidden on given OS (or filesystem) or not
922
    :rtype: bool
923
    '''
924
    if destiny_os == 'nt':
925
        fpc = filename.split('.', 1)[0].upper()
926
        if fpc in nt_device_names:
927
            return True
928
929
    return filename in restricted_names
930
931
932
def check_base(path, base, os_sep=os.sep):
933
    '''
934
    Check if given absolute path is under or given base.
935
936
    :param path: absolute path
937
    :param base: absolute base path
938
    :return: wether file is under given base or not
939
    :rtype: bool
940
    '''
941
    base = base[:-len(os_sep)] if base.endswith(os_sep) else base
942
    return path == base or check_under_base(path, base, os_sep)
943
944
945
def check_under_base(path, base, os_sep=os.sep):
946
    '''
947
    Check if given absolute path is under given base.
948
949
    :param path: absolute path
950
    :param base: absolute base path
951
    :return: wether file is under given base or not
952
    :rtype: bool
953
    '''
954
    prefix = base if base.endswith(os_sep) else base + os_sep
955
    return path.startswith(prefix)
956
957
958
def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING):
959
    '''
960
    Get rid of parent path components and special filenames.
961
962
    If path is invalid or protected, return empty string.
963
964
    :param path: unsafe path
965
    :type: str
966
    :param destiny_os: destination operative system
967
    :type destiny_os: str
968
    :return: filename or empty string
969
    :rtype: str
970
    '''
971
    path = generic_filename(path)
972
    path = clean_restricted_chars(path)
973
974
    if check_forbidden_filename(path, destiny_os=destiny_os):
975
        return ''
976
977
    if isinstance(path, bytes):
978
        path = path.decode('latin-1', errors=underscore_replace)
979
980
    # Decode and recover from filesystem encoding in order to strip unwanted
981
    # characters out
982
    kwargs = dict(
983
        os_name=destiny_os,
984
        fs_encoding=fs_encoding,
985
        errors=underscore_replace
986
        )
987
    fs_encoded_path = compat.fsencode(path, **kwargs)
988
    fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs)
989
    return fs_decoded_path
990
991
992
def alternative_filename(filename, attempt=None):
993
    '''
994
    Generates an alternative version of given filename.
995
996
    If an number attempt parameter is given, will be used on the alternative
997
    name, a random value will be used otherwise.
998
999
    :param filename: original filename
1000
    :param attempt: optional attempt number, defaults to null
1001
    :return: new filename
1002
    :rtype: str or unicode
1003
    '''
1004
    filename_parts = filename.rsplit(u'.', 2)
1005
    name = filename_parts[0]
1006
    ext = ''.join(u'.%s' % ext for ext in filename_parts[1:])
1007
    if attempt is None:
1008
        choose = random.choice
1009
        extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8))
1010
    else:
1011
        extra = u' (%d)' % attempt
1012
    return u'%s%s%s' % (name, extra, ext)
1013