summaryrefslogtreecommitdiff
path: root/fswrap.py
blob: a1254f35ecabee23188a1610ec608295a6652b35 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# -*- coding: utf-8 -*-
"""
Unified object oriented interface for interacting with file system objects.
File system operations in python are distributed across modules: os, os.path,
fnamtch, shutil and distutils. This module attempts to make the right choices
for common operations to provide a single interface.
"""

import codecs
from datetime import datetime
import mimetypes
import os
import shutil
from distutils import dir_util
import functools
import fnmatch
import logging

logger = logging.getLogger('fswrap')

# pylint: disable-msg=E0611


__all__ = ['File', 'Folder']


class FS(object):
    """
    The base file system object
    """

    def __init__(self, path):
        super(FS, self).__init__()
        if path == os.sep:
            self.path = path
        else:
            self.path = os.path.expandvars(os.path.expanduser(
                        unicode(path).strip().rstrip(os.sep)))

    def __str__(self):
        return self.path

    def __repr__(self):
        return self.path

    def __eq__(self, other):
        return unicode(self) == unicode(other)

    def __ne__(self, other):
        return unicode(self) != unicode(other)

    @property
    def fully_expanded_path(self):
        """
        Returns the absolutely absolute path. Calls os.(
        normpath, normcase, expandvars and expanduser).
        """
        return os.path.abspath(
        os.path.normpath(
        os.path.normcase(
        os.path.expandvars(
        os.path.expanduser(self.path)))))

    @property
    def exists(self):
        """
        Does the file system object exist?
        """
        return os.path.exists(self.path)

    @property
    def name(self):
        """
        Returns the name of the FS object with its extension
        """
        return os.path.basename(self.path)

    @property
    def parent(self):
        """
        The parent folder. Returns a `Folder` object.
        """
        return Folder(os.path.dirname(self.path))

    @property
    def depth(self):
        """
        Returns the number of ancestors of this directory.
        """
        return len(self.path.rstrip(os.sep).split(os.sep))

    def ancestors(self, stop=None):
        """
        Generates the parents until stop or the absolute
        root directory is reached.
        """
        folder = self
        while folder.parent != stop:
            if folder.parent == folder:
                return
            yield folder.parent
            folder = folder.parent

    def is_descendant_of(self, ancestor):
        """
        Checks if this folder is inside the given ancestor.
        """
        stop = Folder(ancestor)
        for folder in self.ancestors():
            if folder == stop:
                return True
            if stop.depth > folder.depth:
                return False
        return False

    def get_relative_path(self, root):
        """
        Gets the fragment of the current path starting at root.
        """
        if self.path == root:
            return ''
        ancestors = self.ancestors(stop=root)
        return functools.reduce(lambda f, p: Folder(p.name).child(f),
                                            ancestors,
                                            self.name)

    def get_mirror(self, target_root, source_root=None):
        """
        Returns a File or Folder object that reperesents if the entire
        fragment of this directory starting with `source_root` were copied
        to `target_root`.

        >>> Folder('/usr/local/hyde/stuff').get_mirror('/usr/tmp',
                                                source_root='/usr/local/hyde')
        Folder('/usr/tmp/stuff')
        """
        fragment = self.get_relative_path(
                        source_root if source_root else self.parent)
        return Folder(target_root).child(fragment)

    @staticmethod
    def file_or_folder(path):
        """
        Returns a File or Folder object that would represent the given path.
        """
        target = unicode(path)
        return Folder(target) if os.path.isdir(target) else File(target)

    def __get_destination__(self, destination):
        """
        Returns a File or Folder object that would represent this entity
        if it were copied or moved to `destination`.
        """
        if isinstance(destination, File) or os.path.isfile(unicode(destination)):
            return destination
        else:
            return FS.file_or_folder(Folder(destination).child(self.name))


class File(FS):
    """
    The File object.
    """

    def __init__(self, path):
        super(File, self).__init__(path)

    @property
    def name_without_extension(self):
        """
        Returns the name of the FS object without its extension
        """
        return os.path.splitext(self.name)[0]

    @property
    def extension(self):
        """
        File extension prefixed with a dot.
        """
        return os.path.splitext(self.path)[1]

    @property
    def kind(self):
        """
        File extension without dot prefix.
        """
        return self.extension.lstrip(".")

    @property
    def size(self):
        """
        Size of this file.
        """

        if not self.exists:
            return -1
        return os.path.getsize(self.path)

    @property
    def mimetype(self):
        """
        Gets the mimetype of this file.
        """
        (mime, _) = mimetypes.guess_type(self.path)
        return mime

    @property
    def is_binary(self):
        """Return true if this is a binary file."""
        with open(self.path, 'rb') as fin:
            CHUNKSIZE = 1024
            while 1:
                chunk = fin.read(CHUNKSIZE)
                if '\0' in chunk:
                    return True
                if len(chunk) < CHUNKSIZE:
                    break
        return False

    @property
    def is_text(self):
        """Return true if this is a text file."""
        return (not self.is_binary)

    @property
    def is_image(self):
        """Return true if this is an image file."""
        return self.mimetype.split("/")[0] == "image"

    @property
    def last_modified(self):
        """
        Returns a datetime object representing the last modified time.
        Calls os.path.getmtime.

        """
        return datetime.fromtimestamp(os.path.getmtime(self.path))

    def has_changed_since(self, basetime):
        """
        Returns True if the file has been changed since the given time.

        """
        return self.last_modified > basetime

    def older_than(self, another_file):
        """
        Checks if this file is older than the given file. Uses last_modified to
        determine age.

        """
        return self.last_modified < File(unicode(another_file)).last_modified

    @staticmethod
    def make_temp(text):
        """
        Creates a temprorary file and writes the `text` into it
        """
        import tempfile
        (handle, path) = tempfile.mkstemp(text=True)
        os.close(handle)
        afile = File(path)
        afile.write(text)
        return afile

    def read_all(self, encoding='utf-8'):
        """
        Reads from the file and returns the content as a string.
        """
        logger.info("Reading everything from %s" % self)
        with codecs.open(self.path, 'r', encoding) as fin:
            read_text = fin.read()
        return read_text

    def write(self, text, encoding="utf-8"):
        """
        Writes the given text to the file using the given encoding.
        """
        logger.info("Writing to %s" % self)
        with codecs.open(self.path, 'w', encoding) as fout:
            fout.write(text)

    def copy_to(self, destination):
        """
        Copies the file to the given destination. Returns a File
        object that represents the target file. `destination` must
        be a File or Folder object.
        """
        target = self.__get_destination__(destination)
        logger.info("Copying %s to %s" % (self, target))
        shutil.copy(self.path, unicode(destination))
        return target

    def delete(self):
        """
        Delete the file if it exists.
        """
        if self.exists:
            os.remove(self.path)

    @property
    def etag(self):
        """
        Generates etag from file contents.
        """
        CHUNKSIZE = 1024 * 64
        from hashlib import md5
        hash = md5()
        with open(self.path) as fin:
            chunk = fin.read(CHUNKSIZE)
            while chunk:
                hash.update(chunk)
                chunk = fin.read(CHUNKSIZE)
        return hash.hexdigest()


class FSVisitor(object):
    """
    Implements syntactic sugar for walking and listing folders
    """

    def __init__(self, folder, pattern=None):
        super(FSVisitor, self).__init__()
        self.folder = folder
        self.pattern = pattern

    def folder_visitor(self, function):
        """
        Decorator for `visit_folder` protocol
        """
        self.visit_folder = function
        return function

    def file_visitor(self, function):
        """
        Decorator for `visit_file` protocol
        """
        self.visit_file = function
        return function

    def finalizer(self, function):
        """
        Decorator for `visit_complete` protocol
        """
        self.visit_complete = function
        return function

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        pass


class FolderWalker(FSVisitor):
    """
    Walks the entire hirearchy of this directory starting with itself.

    If a pattern is provided, only the files that match the pattern are
    processed.
    """

    def walk(self, walk_folders=False, walk_files=False):
        """
        A simple generator that yields a File or Folder object based on
        the arguments.
        """

        if not walk_files and not walk_folders:
            return

        for root, _, a_files in os.walk(self.folder.path, followlinks=True):
            folder = Folder(root)
            if walk_folders:
                yield folder
            if walk_files:
                for a_file in a_files:
                    if (not self.pattern or
                        fnmatch.fnmatch(a_file, self.pattern)):
                        yield File(folder.child(a_file))

    def walk_all(self):
        """
        Yield both Files and Folders as the tree is walked.
        """

        return self.walk(walk_folders=True, walk_files=True)

    def walk_files(self):
        """
        Yield only Files.
        """
        return self.walk(walk_folders=False, walk_files=True)

    def walk_folders(self):
        """
        Yield only Folders.
        """
        return self.walk(walk_folders=True, walk_files=False)

    def __exit__(self, exc_type, exc_val, exc_tb):
        """
        Automatically walk the folder when the context manager is exited.

        Calls self.visit_folder first and then calls self.visit_file for
        any files found. After all files and folders have been exhausted
        self.visit_complete is called.

        If visitor.visit_folder returns False, the files in the folder are not
        processed.
        """

        def __visit_folder__(folder):
            process_folder = True
            if hasattr(self, 'visit_folder'):
                process_folder = self.visit_folder(folder)
                # If there is no return value assume true
                #
                if process_folder is None:
                    process_folder = True
            return process_folder

        def __visit_file__(a_file):
            if hasattr(self, 'visit_file'):
                self.visit_file(a_file)

        def __visit_complete__():
            if hasattr(self, 'visit_complete'):
                self.visit_complete()

        for root, dirs, a_files in os.walk(self.folder.path, followlinks=True):
            folder = Folder(root)
            if not __visit_folder__(folder):
                dirs[:] = []
                continue
            for a_file in a_files:
                if not self.pattern or fnmatch.fnmatch(a_file, self.pattern):
                    __visit_file__(File(folder.child(a_file)))
        __visit_complete__()


class FolderLister(FSVisitor):
    """
    Lists the contents of this directory.

    If a pattern is provided, only the files that match the pattern are
    processed.
    """

    def list(self, list_folders=False, list_files=False):
        """
        A simple generator that yields a File or Folder object based on
        the arguments.
        """

        a_files = os.listdir(self.folder.path)
        for a_file in a_files:
            path = self.folder.child(a_file)
            if os.path.isdir(path):
                if list_folders:
                    yield Folder(path)
            elif list_files:
                if not self.pattern or fnmatch.fnmatch(a_file, self.pattern):
                    yield File(path)

    def list_all(self):
        """
        Yield both Files and Folders as the folder is listed.
        """

        return self.list(list_folders=True, list_files=True)

    def list_files(self):
        """
        Yield only Files.
        """
        return self.list(list_folders=False, list_files=True)

    def list_folders(self):
        """
        Yield only Folders.
        """
        return self.list(list_folders=True, list_files=False)

    def __exit__(self, exc_type, exc_val, exc_tb):
        """
        Automatically list the folder contents when the context manager
        is exited.

        Calls self.visit_folder first and then calls self.visit_file for
        any files found. After all files and folders have been exhausted
        self.visit_complete is called.
        """

        a_files = os.listdir(self.folder.path)
        for a_file in a_files:
            path = self.folder.child(a_file)
            if os.path.isdir(path) and hasattr(self, 'visit_folder'):
                self.visit_folder(Folder(path))
            elif hasattr(self, 'visit_file'):
                if not self.pattern or fnmatch.fnmatch(a_file, self.pattern):
                    self.visit_file(File(path))
        if hasattr(self, 'visit_complete'):
            self.visit_complete()


class Folder(FS):
    """
    Represents a directory.
    """

    def __init__(self, path):
        super(Folder, self).__init__(path)

    def child_folder(self, fragment):
        """
        Returns a folder object by combining the fragment to this folder's path
        """
        return Folder(os.path.join(self.path, Folder(fragment).path))

    def child_file(self, fragment):
        """
        Returns a `File` object representing the `fragment`.
        """
        return File(self.child(fragment))

    def child(self, fragment):
        """
        Returns a path of a child item represented by `fragment`.
        """
        return os.path.join(self.path, FS(fragment).path)

    def make(self):
        """
        Creates this directory and any of the missing directories in the path.
        Any errors that may occur are eaten.
        """
        try:
            if not self.exists:
                logger.info("Creating %s" % self.path)
                os.makedirs(self.path)
        except os.error:
            pass
        return self

    def zip(self, target=None, basepath=None):
        """
        Zips the contents of this folder. If `target` is not provided,
        <name>.zip is used instead. `basepath` is used to specify the
        base path for files in the archive. The path stored along with
        the files in the archive will be relative to the `basepath`.
        """
        target = self.parent.child(target or self.name + '.zip')
        basepath = basepath or self.path
        from zipfile import ZipFile
        with ZipFile(target, 'w') as zip:
            with self.walker as walker:
                @walker.file_visitor
                def add_file(f):
                    zip.write(f.path, f.get_relative_path(basepath))

    def delete(self):
        """
        Deletes the directory if it exists.
        """
        if self.exists:
            logger.info("Deleting %s" % self.path)
            shutil.rmtree(self.path)

    def copy_to(self, destination):
        """
        Copies this directory to the given destination. Returns a Folder object
        that represents the moved directory.
        """
        target = self.__get_destination__(destination)
        logger.info("Copying %s to %s" % (self, target))
        shutil.copytree(self.path, unicode(target))
        return target

    def move_to(self, destination):
        """
        Moves this directory to the given destination. Returns a Folder object
        that represents the moved directory.
        """
        target = self.__get_destination__(destination)
        logger.info("Move %s to %s" % (self, target))
        shutil.move(self.path, unicode(target))
        return target

    def rename_to(self, destination_name):
        """
        Moves this directory to the given destination. Returns a Folder object
        that represents the moved directory.
        """
        target = self.parent.child_folder(destination_name)
        logger.info("Rename %s to %s" % (self, target))
        shutil.move(self.path, unicode(target))
        return target

    def _create_target_tree(self, target):
        """
        There is a bug in dir_util that makes `copy_tree` crash if a folder in
        the tree has been deleted before and readded now. To workaround the
        bug, we first walk the tree and create directories that are needed.
        """
        source = self
        with source.walker as walker:

            @walker.folder_visitor
            def visit_folder(folder):
                """
                Create the mirror directory
                """
                if folder != source:
                    Folder(folder.get_mirror(target, source)).make()

    def copy_contents_to(self, destination):
        """
        Copies the contents of this directory to the given destination.
        Returns a Folder object that represents the moved directory.
        """
        logger.info("Copying contents of %s to %s" % (self, destination))
        target = Folder(destination)
        target.make()
        self._create_target_tree(target)
        dir_util.copy_tree(self.path, unicode(target))
        return target

    def get_walker(self, pattern=None):
        """
        Return a `FolderWalker` object with a set pattern.
        """
        return FolderWalker(self, pattern)

    @property
    def walker(self):
        """
        Return a `FolderWalker` object
        """
        return FolderWalker(self)

    def get_lister(self, pattern=None):
        """
        Return a `FolderLister` object with a set pattern.
        """
        return FolderLister(self, pattern)

    @property
    def lister(self):
        """
        Return a `FolderLister` object
        """
        return FolderLister(self)