1# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX
2# All rights reserved.
3#
4# This software is provided without warranty under the terms of the BSD
5# license included in LICENSE.txt and may be redistributed only under
6# the conditions described in the aforementioned license. The license
7# is also available online at http://www.enthought.com/licenses/BSD.txt
8#
9# Thanks for using Enthought open source!
10#  Date:   11/03/2007
11
12""" Defines the ImageLibrary object used to manage Pyface image libraries.
13"""
14
15import sys
16from os import (
17    environ,
18    listdir,
19    remove,
20    stat,
21    makedirs,
22    rename,
23    access,
24    R_OK,
25    W_OK,
26    X_OK,
27)
28from os.path import (
29    join,
30    isdir,
31    isfile,
32    splitext,
33    abspath,
34    dirname,
35    basename,
36    exists,
37)
38from stat import ST_MTIME
39from platform import system
40from zipfile import is_zipfile, ZipFile, ZIP_DEFLATED
41import datetime
42import time
43from _thread import allocate_lock
44from threading import Thread
45
46from traits.api import (
47    HasPrivateTraits,
48    Property,
49    Str,
50    Int,
51    List,
52    Dict,
53    File,
54    Instance,
55    Bool,
56    Undefined,
57    TraitError,
58    Float,
59    Any,
60    cached_property,
61)
62from traits.trait_base import get_resource_path, traits_home
63
64from pyface.api import ImageResource
65from pyface.resource_manager import resource_manager
66from pyface.resource.resource_reference import (
67    ImageReference,
68    ResourceReference,
69)
70from pyface.ui_traits import HasMargin, HasBorder, Alignment
71
72# ---------------------------------------------------------------------------
73#  Constants:
74# ---------------------------------------------------------------------------
75
76# Standard image file extensions:
77ImageFileExts = (".png", ".gif", ".jpg", "jpeg")
78
79# The image_cache root directory:
80image_cache_path = join(traits_home(), "image_cache")
81
82# Names of files that should not be copied when ceating a new library copy:
83dont_copy_list = ("image_volume.py", "image_info.py", "license.txt")
84
85# -- Code Generation Templates ----------------------------------------------
86
87# Template for creating an ImageVolumeInfo object:
88ImageVolumeInfoCodeTemplate = """        ImageVolumeInfo(
89            description=%(description)s,
90            copyright=%(copyright)s,
91            license=%(license)s,
92            image_names=%(image_names)s
93        )"""
94
95# Template for creating an ImageVolumeInfo license text:
96ImageVolumeInfoTextTemplate = """Description:
97    %s
98
99Copyright:
100    %s
101
102License:
103    %s
104
105Applicable Images:
106%s"""
107
108# Template for creating an ImageVolume object:
109ImageVolumeTemplate = """from pyface.image.image import ImageVolume, ImageVolumeInfo
110
111volume = ImageVolume(
112    category=%(category)s,
113    keywords=%(keywords)s,
114    aliases=%(aliases)s,
115    time_stamp=%(time_stamp)s,
116    info=[
117%(info)s
118    ]
119)"""
120
121# Template for creating an ImageVolume 'images' list:
122ImageVolumeImagesTemplate = """from pyface.image.image import ImageInfo
123from pyface.ui_traits import Margin, Border
124
125images = [
126%s
127]"""
128
129# Template for creating an ImageInfo object:
130ImageInfoTemplate = """    ImageInfo(
131        name=%(name)s,
132        image_name=%(image_name)s,
133        description=%(description)s,
134        category=%(category)s,
135        keywords=%(keywords)s,
136        width=%(width)d,
137        height=%(height)d,
138        border=Border(%(bleft)d, %(bright)d, %(btop)d, %(bbottom)d),
139        content=Margin(%(cleft)d, %(cright)d, %(ctop)d, %(cbottom)d),
140        label=Margin(%(lleft)d, %(lright)d, %(ltop)d, %(lbottom)d),
141        alignment=%(alignment)s
142    )"""
143
144
145def read_file(file_name):
146    """ Returns the contents of the specified *file_name*.
147    """
148    with open(file_name, "rb") as fh:
149        return fh.read()
150
151
152def write_file(file_name, data):
153    """ Writes the specified data to the specified file.
154    """
155    with open(file_name, "wb") as fh:
156        fh.write(data)
157
158
159def get_python_value(source, name):
160    """ Returns the value of a Python symbol loaded from a specified source
161        code string.
162    """
163    temp = {}
164    exec(source.replace(b"\r", b""), globals(), temp)
165    return temp[name]
166
167
168def time_stamp_for(time):
169    """ Returns a specified time as a text string.
170    """
171    return datetime.datetime.utcfromtimestamp(time).strftime("%Y%m%d%H%M%S")
172
173
174def add_object_prefix(dict, object, prefix):
175    """ Adds all traits from a specified object to a dictionary with a specified
176        name prefix.
177    """
178    for name, value in object.trait_get().items():
179        dict[prefix + name] = value
180
181
182def split_image_name(image_name):
183    """ Splits a specified **image_name** into its constituent volume and file
184        names and returns a tuple of the form: ( volume_name, file_name ).
185    """
186    col = image_name.find(":")
187    volume_name = image_name[1:col]
188    file_name = image_name[col + 1 :]
189    if file_name.find(".") < 0:
190        file_name += ".png"
191
192    return (volume_name, file_name)
193
194
195def join_image_name(volume_name, file_name):
196    """ Joins a specified **volume_name** and **file_name** into an image name,
197        and return the resulting image name.
198    """
199    root, ext = splitext(file_name)
200    if (ext == ".png") and (root.find(".") < 0):
201        file_name = root
202
203    return "@%s:%s" % (volume_name, file_name)
204
205
206class FastZipFile(HasPrivateTraits):
207    """ Provides fast access to zip files by keeping the underlying zip file
208        open across multiple uses.
209    """
210
211    #: The path to the zip file:
212    path = File()
213
214    #: The open zip file object (if None, the file is closed):
215    zf = Property
216
217    #: The time stamp of when the zip file was most recently accessed:
218    time_stamp = Float()
219
220    #: The lock used to manage access to the 'zf' trait between the two threads:
221    access = Any()
222
223    # -- Public Methods ---------------------------------------------------------
224
225    def namelist(self):
226        """ Returns the names of all files in the top-level zip file directory.
227        """
228        self.access.acquire()
229        try:
230            return self.zf.namelist()
231        finally:
232            self.access.release()
233
234    def read(self, file_name):
235        """ Returns the contents of the specified **file_name** from the zip
236            file.
237        """
238        self.access.acquire()
239        try:
240            return self.zf.read(file_name)
241        finally:
242            self.access.release()
243
244    def close(self):
245        """ Temporarily closes the zip file (usually while the zip file is being
246            replaced by a different version).
247        """
248        self.access.acquire()
249        try:
250            if self._zf is not None:
251                self._zf.close()
252                self._zf = None
253        finally:
254            self.access.release()
255
256    # -- Default Value Implementations ------------------------------------------
257
258    def _access_default(self):
259        return allocate_lock()
260
261    # -- Property Implementations -----------------------------------------------
262
263    def _get_zf(self):
264        # Restart the time-out:
265        self.time_stamp = time.time()
266
267        if self._zf is None:
268            self._zf = ZipFile(self.path, "r")
269            if self._running is None:
270                Thread(target=self._process).start()
271                self._running = True
272
273        return self._zf
274
275    # -- Private Methods --------------------------------------------------------
276
277    def _process(self):
278        """ Waits until the zip file has not been accessed for a while, then
279            closes the file and exits.
280        """
281        while True:
282            time.sleep(1)
283            self.access.acquire()
284            if time.time() > (self.time_stamp + 2.0):
285                if self._zf is not None:
286                    self._zf.close()
287                    self._zf = None
288
289                self._running = None
290                self.access.release()
291                break
292
293            self.access.release()
294
295
296# -------------------------------------------------------------------------------
297#  'ImageInfo' class:
298# -------------------------------------------------------------------------------
299
300
301class ImageInfo(HasPrivateTraits):
302    """ Defines a class that contains information about a specific Traits UI
303        image.
304    """
305
306    #: The volume this image belongs to:
307    volume = Instance("ImageVolume")
308
309    #: The user friendly name of the image:
310    name = Str()
311
312    #: The full image name (e.g. '@standard:floppy'):
313    image_name = Str()
314
315    #: A description of the image:
316    description = Str()
317
318    #: The category that the image belongs to:
319    category = Str("General")
320
321    #: A list of keywords used to describe/categorize the image:
322    keywords = List(Str)
323
324    #: The image width (in pixels):
325    width = Int()
326
327    #: The image height (in pixels):
328    height = Int()
329
330    #: The border inset:
331    border = HasBorder
332
333    #: The margin to use around the content:
334    content = HasMargin
335
336    #: The margin to use around the label:
337    label = HasMargin
338
339    #: The alignment to use for the label:
340    alignment = Alignment
341
342    #: The copyright that applies to this image:
343    copyright = Property
344
345    #: The license that applies to this image:
346    license = Property
347
348    #: A read-only string containing the Python code needed to construct this
349    #: ImageInfo object:
350    image_info_code = Property
351
352    # -- Default Value Implementations ------------------------------------------
353
354    def _name_default(self):
355        return split_image_name(self.image_name)[1]
356
357    def _width_default(self):
358        if self.volume is None:
359            return 0
360
361        image = self.volume.image_resource(self.image_name)
362        if image is None:
363            self.height = 0
364            return 0
365
366        width, self.height = image.image_size(image.create_image())
367
368        return width
369
370    def _height_default(self):
371        if self.volume is None:
372            return 0
373
374        image = self.volume.image_resource(self.image_name)
375        if image is None:
376            self.width = 0
377            return 0
378
379        self.width, height = image.image_size(image.create_image())
380
381        return height
382
383    # -- Property Implementations -----------------------------------------------
384
385    def _get_image_info_code(self):
386        data = dict(
387            (name, repr(value))
388            for name, value in self.trait_get(
389                "name",
390                "image_name",
391                "description",
392                "category",
393                "keywords",
394                "alignment",
395            ).items()
396        )
397        data.update(self.trait_get("width", "height"))
398        sides = ["left", "right", "top", "bottom"]
399        data.update(("b" + name, getattr(self.border, name)) for name in sides)
400        data.update(
401            ("c" + name, getattr(self.content, name)) for name in sides
402        )
403        data.update(("l" + name, getattr(self.label, name)) for name in sides)
404        return ImageInfoTemplate % data
405
406    def _get_copyright(self):
407        return self._volume_info("copyright")
408
409    def _get_license(self):
410        return self._volume_info("license")
411
412    # -- Private Methods --------------------------------------------------------
413
414    def _volume_info(self, name):
415        """ Returns the VolumeInfo object that applies to this image.
416        """
417        info = self.volume.volume_info(self.image_name)
418        if info is not None:
419            return getattr(info, name, "Unknown")
420
421        return "Unknown"
422
423
424# -------------------------------------------------------------------------------
425#  'ImageVolumeInfo' class:
426# -------------------------------------------------------------------------------
427
428
429class ImageVolumeInfo(HasPrivateTraits):
430
431    #: A general description of the images:
432    description = Str("No volume description specified.")
433
434    #: The copyright that applies to the images:
435    copyright = Str("No copyright information specified.")
436
437    #: The license that applies to the images:
438    license = Str("No license information specified.")
439
440    #: The list of image names within the volume the information applies to.
441    #: Note that an empty list means that the information applies to all images
442    #: in the volume:
443    image_names = List(Str)
444
445    #: A read-only string containing the Python code needed to construct this
446    #: ImageVolumeInfo object:
447    image_volume_info_code = Property
448
449    #: A read-only string containing the text describing the volume info:
450    image_volume_info_text = Property
451
452    # -- Property Implementations -----------------------------------------------
453
454    @cached_property
455    def _get_image_volume_info_code(self):
456        data = dict(
457            (name, repr(getattr(self, name)))
458            for name in ["description", "copyright", "license", "image_names"]
459        )
460
461        return ImageVolumeInfoCodeTemplate % data
462
463    @cached_property
464    def _get_image_volume_info_text(self):
465        description = self.description.replace("\n", "\n    ")
466        license = self.license.replace("\n", "\n    ").strip()
467        image_names = self.image_names
468        image_names.sort()
469        if len(image_names) == 0:
470            image_names = ["All"]
471        images = "\n".join(["  - " + image_name for image_name in image_names])
472
473        return ImageVolumeInfoTextTemplate % (
474            description,
475            self.copyright,
476            license,
477            images,
478        )
479
480    # -- Public Methods ---------------------------------------------------------
481
482    def clone(self):
483        """ Returns a copy of the ImageVolumeInfo object.
484        """
485        return self.clone(["description", "copyright", "license"])
486
487
488# -------------------------------------------------------------------------------
489#  'ImageVolume' class:
490# -------------------------------------------------------------------------------
491
492
493class ImageVolume(HasPrivateTraits):
494
495    #: The canonical name of this volume:
496    name = Str()
497
498    #: The list of volume descriptors that apply to this volume:
499    info = List(ImageVolumeInfo)
500
501    #: The category that the volume belongs to:
502    category = Str("General")
503
504    #: A list of keywords used to describe the volume:
505    keywords = List(Str)
506
507    #: The list of aliases for this volume:
508    aliases = List(Str)
509
510    #: The path of the file that defined this volume:
511    path = File()
512
513    #: Is the path a zip file?
514    is_zip_file = Bool(True)
515
516    #: The FastZipFile object used to access the underlying zip file:
517    zip_file = Instance(FastZipFile)
518
519    #: The list of images available in the volume:
520    images = List(ImageInfo)
521
522    #: A dictionary mapping image names to ImageInfo objects:
523    catalog = Property(depends_on="images")
524
525    #: The time stamp of when the image library was last modified:
526    time_stamp = Str()
527
528    #: A read-only string containing the Python code needed to construct this
529    #: ImageVolume object:
530    image_volume_code = Property
531
532    #: A read-only string containing the Python code needed to construct the
533    #: 'images' list for this ImageVolume object:
534    images_code = Property
535
536    #: A read-only string containing the text describing the contents of the
537    #: volume (description, copyright, license information, and the images they
538    #: apply to):
539    license_text = Property
540
541    # -- Public Methods ---------------------------------------------------------
542
543    def update(self):
544        """ Updates the contents of the image volume from the underlying
545            image store, and saves the results.
546        """
547        # Unlink all our current images:
548        for image in self.images:
549            image.volume = None
550
551        # Make sure the images are up to date by deleting any current value:
552        self.reset_traits(["images"])
553
554        # Save the new image volume information:
555        self.save()
556
557    def save(self):
558        """ Saves the contents of the image volume using the current contents
559            of the **ImageVolume**.
560        """
561        path = self.path
562
563        if not self.is_zip_file:
564            # Make sure the directory is writable:
565            if not access(path, R_OK | W_OK | X_OK):
566                return False
567
568        # Make sure the directory and zip file are writable:
569        elif (not access(dirname(path), R_OK | W_OK | X_OK)) or (
570            exists(path) and (not access(path, W_OK))
571        ):
572            return False
573
574        # Pre-compute the images code, because it can require a long time
575        # to load all of the images so that we can determine their size, and we
576        # don't want that time to interfere with the time stamp of the image
577        # volume:
578        images_code = self.images_code
579
580        if not self.is_zip_file:
581            # We need to time stamp when this volume info was generated, but
582            # it needs to be the same or newer then the time stamp of the file
583            # it is in. So we use the current time plus a 'fudge factor' to
584            # allow for some slop in when the OS actually time stamps the file:
585            self.time_stamp = time_stamp_for(time.time() + 5.0)
586
587            # Write the volume manifest source code to a file:
588            write_file(join(path, "image_volume.py"), self.image_volume_code)
589
590            # Write the image info source code to a file:
591            write_file(join(path, "image_info.py"), images_code)
592
593            # Write a separate license file for human consumption:
594            write_file(join(path, "license.txt"), self.license_text)
595
596            return True
597
598        # Create a temporary name for the new .zip file:
599        file_name = path + ".###"
600
601        # Create the new zip file:
602        new_zf = ZipFile(file_name, "w", ZIP_DEFLATED)
603
604        try:
605            # Get the current zip file:
606            cur_zf = self.zip_file
607
608            # Copy all of the image files from the current zip file to the new
609            # zip file:
610            for name in cur_zf.namelist():
611                if name not in dont_copy_list:
612                    new_zf.writestr(name, cur_zf.read(name))
613
614            # Temporarily close the current zip file while we replace it with
615            # the new version:
616            cur_zf.close()
617
618            # We need to time stamp when this volume info was generated, but
619            # it needs to be the same or newer then the time stamp of the file
620            # it is in. So we use the current time plus a 'fudge factor' to
621            # allow for some slop in when the OS actually time stamps the file:
622            self.time_stamp = time_stamp_for(time.time() + 10.0)
623
624            # Write the volume manifest source code to the zip file:
625            new_zf.writestr("image_volume.py", self.image_volume_code)
626
627            # Write the image info source code to the zip file:
628            new_zf.writestr("image_info.py", images_code)
629
630            # Write a separate license file for human consumption:
631            new_zf.writestr("license.txt", self.license_text)
632
633            # Done creating the new zip file:
634            new_zf.close()
635            new_zf = None
636
637            # Rename the original file to a temporary name, so we can give the
638            # new file the original name. Note that unlocking the original zip
639            # file after the previous close sometimes seems to take a while,
640            # which is why we repeatedly try the rename until it either succeeds
641            # or takes so long that it must have failed for another reason:
642            temp_name = path + ".$$$"
643            for i in range(50):
644                try:
645                    rename(path, temp_name)
646                    break
647                except Exception:
648                    time.sleep(0.1)
649
650            try:
651                rename(file_name, path)
652                file_name = temp_name
653            except:
654                rename(temp_name, path)
655                raise
656        finally:
657            if new_zf is not None:
658                new_zf.close()
659
660            remove(file_name)
661
662        return True
663
664    def image_resource(self, image_name):
665        """ Returns the ImageResource object for the specified **image_name**.
666        """
667        # Get the name of the image file:
668        volume_name, file_name = split_image_name(image_name)
669
670        if self.is_zip_file:
671            # See if we already have the image file cached in the file system:
672            cache_file = self._check_cache(file_name)
673            if cache_file is None:
674                # If not cached, then create a zip file reference:
675                ref = ZipFileReference(
676                    resource_factory=resource_manager.resource_factory,
677                    zip_file=self.zip_file,
678                    path=self.path,
679                    volume_name=self.name,
680                    file_name=file_name,
681                )
682            else:
683                # Otherwise, create a cache file reference:
684                ref = ImageReference(
685                    resource_manager.resource_factory, filename=cache_file
686                )
687        else:
688            # Otherwise, create a normal file reference:
689            ref = ImageReference(
690                resource_manager.resource_factory,
691                filename=join(self.path, file_name),
692            )
693
694        # Create the ImageResource object using the reference (note that the
695        # ImageResource class will not allow us to specify the reference in the
696        # constructor):
697        resource = ImageResource(file_name)
698        resource._ref = ref
699
700        # Return the ImageResource:
701        return resource
702
703    def image_data(self, image_name):
704        """ Returns the image data (i.e. file contents) for the specified image
705            name.
706        """
707        volume_name, file_name = split_image_name(image_name)
708
709        if self.is_zip_file:
710            return self.zip_file.read(file_name)
711        else:
712            return read_file(join(self.path, file_name))
713
714    def volume_info(self, image_name):
715        """ Returns the ImageVolumeInfo object that corresponds to the
716            image specified by **image_name**.
717        """
718        for info in self.info:
719            if (len(info.image_names) == 0) or (
720                image_name in info.image_names
721            ):
722                return info
723
724        raise ValueError(
725            "Volume info for image name {} not found.".format(repr(info))
726        )
727
728    # -- Default Value Implementations ------------------------------------------
729
730    def _info_default(self):
731        return [ImageVolumeInfo()]
732
733    def _images_default(self):
734        return self._load_image_info()
735
736    # -- Property Implementations -----------------------------------------------
737
738    @cached_property
739    def _get_catalog(self):
740        return dict((image.image_name, image) for image in self.images)
741
742    def _get_image_volume_code(self):
743        data = dict(
744            (name, repr(value))
745            for name, value in self.trait_get(
746                "description", "category", "keywords", "aliases", "time_stamp"
747            ).items()
748        )
749        data["info"] = ",\n".join(
750            info.image_volume_info_code for info in self.info
751        )
752        return ImageVolumeTemplate % data
753
754    def _get_images_code(self):
755        images = ",\n".join(info.image_info_code for info in self.images)
756
757        return ImageVolumeImagesTemplate % images
758
759    def _get_license_text(self):
760        return ("\n\n%s\n" % ("-" * 79)).join(
761            [info.image_volume_info_text for info in self.info]
762        )
763
764    # -- Private Methods --------------------------------------------------------
765
766    def _load_image_info(self):
767        """ Returns the list of ImageInfo objects for the images in the volume.
768        """
769        # If there is no current path, then return a default list of images:
770        if self.path == "":
771            return []
772
773        time_stamp = time_stamp_for(stat(self.path)[ST_MTIME])
774        volume_name = self.name
775        old_images = []
776        cur_images = []
777
778        if self.is_zip_file:
779            zf = self.zip_file
780
781            # Get the names of all top-level entries in the zip file:
782            names = zf.namelist()
783
784            # Check to see if there is an image info manifest file:
785            if "image_info.py" in names:
786                # Load the manifest code and extract the images list:
787                old_images = get_python_value(
788                    zf.read("image_info.py"), "images"
789                )
790
791            # Check to see if our time stamp is up to data with the file:
792            if self.time_stamp < time_stamp:
793
794                # If not, create an ImageInfo object for all image files
795                # contained in the .zip file:
796                for name in names:
797                    root, ext = splitext(name)
798                    if ext in ImageFileExts:
799                        cur_images.append(
800                            ImageInfo(
801                                name=root,
802                                image_name=join_image_name(volume_name, name),
803                            )
804                        )
805
806        else:
807            image_info_path = join(self.path, "image_info.py")
808            if exists(image_info_path):
809                # Load the manifest code and extract the images list:
810                old_images = get_python_value(
811                    read_file(image_info_path), "images"
812                )
813
814            # Check to see if our time stamp is up to data with the file:
815            if self.time_stamp < time_stamp:
816
817                # If not, create an ImageInfo object for each image file
818                # contained in the path:
819                for name in listdir(self.path):
820                    root, ext = splitext(name)
821                    if ext in ImageFileExts:
822                        cur_images.append(
823                            ImageInfo(
824                                name=root,
825                                image_name=join_image_name(volume_name, name),
826                            )
827                        )
828
829        # Merge the old and current images into a single up to date list:
830        if len(cur_images) == 0:
831            images = old_images
832        else:
833            cur_image_set = dict(
834                [(image.image_name, image) for image in cur_images]
835            )
836            for old_image in old_images:
837                cur_image = cur_image_set.get(old_image.image_name)
838                if cur_image is not None:
839                    cur_image_set[old_image.image_name] = old_image
840                    cur_image.volume = self
841                    old_image.width = cur_image.width
842                    old_image.height = cur_image.height
843                    cur_image.volume = None
844
845            images = list(cur_image_set.values())
846
847        # Set the new time stamp of the volume:
848        self.time_stamp = time_stamp
849
850        # Return the resulting sorted list as the default value:
851        images.sort(key=lambda item: item.image_name)
852
853        # Make sure all images reference this volume:
854        for image in images:
855            image.volume = self
856
857        return images
858
859    def _check_cache(self, file_name):
860        """ Checks to see if the specified zip file name has been saved in the
861            image cache. If it has, it returns the fully-qualified cache file
862            name to use; otherwise it returns None.
863        """
864        cache_file = join(image_cache_path, self.name, file_name)
865        if exists(cache_file) and (
866            time_stamp_for(stat(cache_file)[ST_MTIME]) > self.time_stamp
867        ):
868            return cache_file
869
870        return None
871
872
873# -------------------------------------------------------------------------------
874#  'ZipFileReference' class:
875# -------------------------------------------------------------------------------
876
877
878class ZipFileReference(ResourceReference):
879
880    #: The zip file to read;
881    zip_file = Instance(FastZipFile)
882
883    #: The volume name:
884    volume_name = Str()
885
886    #: The file within the zip file:
887    file_name = Str()
888
889    #: The name of the cached image file:
890    cache_file = File()
891
892    # -- The 'ResourceReference' API --------------------------------------------
893
894    #: The file name of the image (in this case, the cache file name):
895    filename = Property
896
897    # -- ResourceReference Interface Implementation -----------------------------
898
899    def load(self):
900        """ Loads the resource.
901        """
902        # Check if the cache file has already been created:
903        cache_file = self.cache_file
904        if cache_file == "":
905            # Extract the data from the zip file:
906            data = self.zip_file.read(self.file_name)
907
908            # Try to create an image from the data, without writing it to a
909            # file first:
910            image = self.resource_factory.image_from_data(data, Undefined)
911            if image is not None:
912                return image
913
914            # Make sure the correct image cache directory exists:
915            cache_dir = join(image_cache_path, self.volume_name)
916            if not exists(cache_dir):
917                makedirs(cache_dir)
918
919            # Write the image data to the cache file:
920            cache_file = join(cache_dir, self.file_name)
921            with open(cache_file, "wb") as fh:
922                fh.write(data)
923
924            # Save the cache file name in case we are called again:
925            self.cache_file = cache_file
926
927            # Release our reference to the zip file object:
928            self.zip_file = None
929
930        # Return the image data from the image cache file:
931        return self.resource_factory.image_from_file(cache_file)
932
933    # -- Property Implementations -----------------------------------------------
934
935    def _get_filename(self):
936        if self.cache_file == "":
937            self.load()
938
939        return self.cache_file
940
941
942# -------------------------------------------------------------------------------
943#  'ImageLibrary' class:
944# -------------------------------------------------------------------------------
945
946
947class ImageLibrary(HasPrivateTraits):
948    """ Manages Traits UI image libraries.
949    """
950
951    #: The list of available image volumes in the library:
952    volumes = List(ImageVolume)
953
954    #: The volume dictionary (the keys are volume names, and the values are the
955    #: corresponding ImageVolume objects):
956    catalog = Dict(Str, ImageVolume)
957
958    #: The list of available images in the library:
959    images = Property(List, depends_on="volumes.images")
960
961    # -- Private Traits ---------------------------------------------------------
962
963    #: Mapping from a 'virtual' library name to a 'real' library name:
964    aliases = Dict()
965
966    # -- Public methods ---------------------------------------------------------
967
968    def image_info(self, image_name):
969        """ Returns the ImageInfo object corresponding to a specified
970            **image_name**.
971        """
972        volume = self.find_volume(image_name)
973        if volume is not None:
974            return volume.catalog.get(image_name)
975
976        return None
977
978    def image_resource(self, image_name):
979        """ Returns an ImageResource object for the specified image name.
980        """
981        # If no volume was specified, use the standard volume:
982        if image_name.find(":") < 0:
983            image_name = "@images:%s" % image_name[1:]
984
985        # Find the correct volume, possible resolving any aliases used:
986        volume = self.find_volume(image_name)
987
988        # Find the image within the volume and return its ImageResource object:
989        if volume is not None:
990            return volume.image_resource(image_name)
991
992        # Otherwise, the volume was not found:
993        return None
994
995    def find_volume(self, image_name):
996        """ Returns the ImageVolume object corresponding to the specified
997            **image_name** or None if the volume cannot be found.
998        """
999        # Extract the volume name from the image name:
1000        volume_name, file_name = split_image_name(image_name)
1001
1002        # Find the correct volume, possibly resolving any aliases used:
1003        catalog = self.catalog
1004        aliases = self.aliases
1005        while volume_name not in catalog:
1006            volume_name = aliases.get(volume_name)
1007            if volume_name is None:
1008                return None
1009
1010        return catalog[volume_name]
1011
1012    def add_volume(self, file_name=None):
1013        """ If **file_name** is a file, it adds an image volume specified by
1014            **file_name** to the image library. If **file_name** is a
1015            directory, it adds all image libraries contained in the directory
1016            to the image library. If **file_name** is omitted, all image
1017            libraries located in the *images* directory contained in the same
1018            directory as the caller are added.
1019        """
1020        # If no file name was specified, derive a path from the caller's
1021        # source code location:
1022        if file_name is None:
1023            file_name = join(get_resource_path(2), "images")
1024
1025        if isfile(file_name):
1026            # Load an image volume from the specified file:
1027            volume = self._add_volume(file_name)
1028            if volume is None:
1029                raise TraitError(
1030                    "'%s' is not a valid image volume." % file_name
1031                )
1032
1033            if volume.name in self.catalog:
1034                self._duplicate_volume(volume.name)
1035
1036            self.catalog[volume.name] = volume
1037            self.volumes.append(volume)
1038
1039        elif isdir(file_name):
1040            # Load all image volumes from the specified path:
1041            catalog = self.catalog
1042            volumes = self._add_path(file_name)
1043            for volume in volumes:
1044                if volume.name in catalog:
1045                    self._duplicate_volume(volume.name)
1046
1047                catalog[volume.name] = volume
1048
1049            self.volumes.extend(volumes)
1050        else:
1051            # Handle an unrecognized argument:
1052            raise TraitError(
1053                "The add method argument must be None or a file "
1054                "or directory path, but '%s' was specified." % file_name
1055            )
1056
1057    def add_path(self, volume_name, path=None):
1058        """ Adds the directory specified by **path** as a *virtual* volume
1059            called **volume_name**. All image files contained within path
1060            define the contents of the volume. If **path** is None, the
1061            *images* contained in the 'images' subdirectory of the same
1062            directory as the caller are is used as the path for the *virtual*
1063            volume..
1064        """
1065        # Make sure we don't already have a volume with that name:
1066        if volume_name in self.catalog:
1067            raise TraitError(
1068                ("The volume name '%s' is already in the image " "library.")
1069                % volume_name
1070            )
1071
1072        # If no path specified, derive one from the caller's source code
1073        # location:
1074        if path is None:
1075            path = join(get_resource_path(2), "images")
1076
1077        # Make sure that the specified path is a directory:
1078        if not isdir(path):
1079            raise TraitError(
1080                "The image volume path '%s' does not exist." % path
1081            )
1082
1083        # Create the ImageVolume to describe the path's contents:
1084        image_volume_path = join(path, "image_volume.py")
1085        if exists(image_volume_path):
1086            volume = get_python_value(read_file(image_volume_path), "volume")
1087        else:
1088            volume = ImageVolume()
1089
1090        # Set up the rest of the volume information:
1091        volume.trait_set(name=volume_name, path=path, is_zip_file=False)
1092
1093        # Try to bring the volume information up to date if necessary:
1094        if volume.time_stamp < time_stamp_for(stat(path)[ST_MTIME]):
1095            # Note that the save could fail if the volume is read-only, but
1096            # that's OK, because we're only trying to do the save in case
1097            # a developer had added or deleted some image files, which would
1098            # require write access to the volume:
1099            volume.save()
1100
1101        # Add the new volume to the library:
1102        self.catalog[volume_name] = volume
1103        self.volumes.append(volume)
1104
1105    def extract(self, file_name, image_names):
1106        """ Builds a new image volume called **file_name** from the list of
1107            image names specified by **image_names**. Each image name should be
1108            of the form: '@volume:name'.
1109        """
1110        # Get the volume name and file extension:
1111        volume_name, ext = splitext(basename(file_name))
1112
1113        # If no extension specified, add the '.zip' file extension:
1114        if ext == "":
1115            file_name += ".zip"
1116
1117        # Create the ImageVolume object to describe the new volume:
1118        volume = ImageVolume(name=volume_name)
1119
1120        # Make sure the zip file does not already exists:
1121        if exists(file_name):
1122            raise TraitError("The '%s' file already exists." % file_name)
1123
1124        # Create the zip file:
1125        zf = ZipFile(file_name, "w", ZIP_DEFLATED)
1126
1127        # Add each of the specified images to it and the ImageVolume:
1128        error = True
1129        aliases = set()
1130        keywords = set()
1131        images = []
1132        info = {}
1133        try:
1134            for image_name in set(image_names):
1135                # Verify the image name is legal:
1136                if (image_name[:1] != "@") or (image_name.find(":") < 0):
1137                    raise TraitError(
1138                        (
1139                            "The image name specified by '%s' is "
1140                            "not of the form: @volume:name."
1141                        )
1142                        % image_name
1143                    )
1144
1145                # Get the reference volume and image file names:
1146                image_volume_name, image_file_name = split_image_name(
1147                    image_name
1148                )
1149
1150                # Get the volume for the image name:
1151                image_volume = self.find_volume(image_name)
1152                if image_volume is None:
1153                    raise TraitError(
1154                        (
1155                            "Could not find the image volume "
1156                            "specified by '%s'."
1157                        )
1158                        % image_name
1159                    )
1160
1161                # Get the image info:
1162                image_info = image_volume.catalog.get(image_name)
1163                if image_info is None:
1164                    raise TraitError(
1165                        ("Could not find the image specified by " "'%s'.")
1166                        % image_name
1167                    )
1168
1169                # Add the image info to the list of images:
1170                images.append(image_info)
1171
1172                # Add the image file to the zip file:
1173                zf.writestr(
1174                    image_file_name, image_volume.image_data(image_name)
1175                )
1176
1177                # Add the volume alias needed by the image (if any):
1178                if image_volume_name != volume_name:
1179                    if image_volume_name not in aliases:
1180                        aliases.add(image_volume_name)
1181
1182                        # Add the volume keywords as well:
1183                        for keyword in image_volume.keywords:
1184                            keywords.add(keyword)
1185
1186                # Add the volume info for the image:
1187                volume_info = image_volume.volume_info(image_name)
1188                vinfo = info.get(image_volume_name)
1189                if vinfo is None:
1190                    info[image_volume_name] = vinfo = volume_info.clone()
1191
1192                vinfo.image_names.append(image_name)
1193
1194            # Create the list of images for the volume:
1195            images.sort(key=lambda item: item.image_name)
1196            volume.images = images
1197
1198            # Create the list of aliases for the volume:
1199            volume.aliases = list(aliases)
1200
1201            # Create the list of keywords for the volume:
1202            volume.keywords = list(keywords)
1203
1204            # Create the final volume info list for the volume:
1205            volume.info = list(info.values())
1206
1207            # Write the volume manifest source code to the zip file:
1208            zf.writestr("image_volume.py", volume.image_volume_code)
1209
1210            # Write the image info source code to the zip file:
1211            zf.writestr("image_info.py", volume.images_code)
1212
1213            # Write a separate licenses file for human consumption:
1214            zf.writestr("license.txt", volume.license_text)
1215
1216            # Indicate no errors occurred:
1217            error = False
1218        finally:
1219            zf.close()
1220            if error:
1221                remove(file_name)
1222
1223    # -- Default Value Implementations ------------------------------------------
1224
1225    def _volumes_default(self):
1226        result = []
1227
1228        # Check for and add the 'application' image library:
1229        app_library = join(dirname(abspath(sys.argv[0])), "library")
1230        if isdir(app_library):
1231            result.extend(self._add_path(app_library))
1232
1233        # Get all volumes in the standard Traits UI image library directory:
1234        result.extend(self._add_path(join(get_resource_path(1), "library")))
1235
1236        # Check to see if there is an environment variable specifying a list
1237        # of paths containing image libraries:
1238        paths = environ.get("TRAITS_IMAGES")
1239        if paths is not None:
1240            # Determine the correct OS path separator to use:
1241            separator = ";"
1242            if system() != "Windows":
1243                separator = ":"
1244
1245            # Add all image volumes found in each path in the environment
1246            # variable:
1247            for path in paths.split(separator):
1248                result.extend(self._add_path(path))
1249
1250        # Return the list of default volumes found:
1251        return result
1252
1253    def _catalog_default(self):
1254        return dict([(volume.name, volume) for volume in self.volumes])
1255
1256    # -- Property Implementations -----------------------------------------------
1257
1258    @cached_property
1259    def _get_images(self):
1260        return self._get_images_list()
1261
1262    # -- Private Methods --------------------------------------------------------
1263
1264    def _get_images_list(self):
1265        """ Returns the list of all library images.
1266        """
1267        # Merge the list of images from each volume:
1268        images = []
1269        for volume in self.volumes:
1270            images.extend(volume.images)
1271
1272        # Sort the result:
1273        images.sort(key=lambda image: image.image_name)
1274
1275        # Return the images list:
1276        return images
1277
1278    def _add_path(self, path):
1279        """ Returns a list of ImageVolume objects, one for each image library
1280            located in the specified **path**.
1281        """
1282        result = []
1283
1284        # Make sure the path is a directory:
1285        if isdir(path):
1286
1287            # Find each zip file in the directory:
1288            for base in listdir(path):
1289                if splitext(base)[1] == ".zip":
1290
1291                    # Try to create a volume from the zip file and add it to
1292                    # the result:
1293                    volume = self._add_volume(join(path, base))
1294                    if volume is not None:
1295                        result.append(volume)
1296
1297        # Return the list of volumes found:
1298        return result
1299
1300    def _add_volume(self, path):
1301        """ Returns an ImageVolume object for the image library specified by
1302            **path**. If **path** does not specify a valid ImageVolume, None is
1303            returned.
1304        """
1305        path = abspath(path)
1306
1307        # Make sure the path is a valid zip file:
1308        if is_zipfile(path):
1309
1310            # Create a fast zip file for reading:
1311            zf = FastZipFile(path=path)
1312
1313            # Extract the volume name from the path:
1314            volume_name = splitext(basename(path))[0]
1315
1316            # Get the names of all top-level entries in the zip file:
1317            names = zf.namelist()
1318
1319            # Check to see if there is a manifest file:
1320            if "image_volume.py" in names:
1321                # Load the manifest code and extract the volume object:
1322                volume = get_python_value(zf.read("image_volume.py"), "volume")
1323
1324                # Set the volume name:
1325                volume.name = volume_name
1326
1327                # Try to add all of the external volume references as
1328                # aliases for this volume:
1329                self._add_aliases(volume)
1330
1331                # Set the path to this volume:
1332                volume.path = path
1333
1334                # Save the reference to the zip file object we are using:
1335                volume.zip_file = zf
1336
1337            else:
1338                # Create a new volume from the zip file:
1339                volume = ImageVolume(name=volume_name, path=path, zip_file=zf)
1340
1341            # If this volume is not up to date, update it:
1342            if volume.time_stamp < time_stamp_for(stat(path)[ST_MTIME]):
1343                # Note that the save could fail if the volume is read-only, but
1344                # that's OK, because we're only trying to do the save in case
1345                # a developer had added or deleted some image files, which would
1346                # require write access to the volume:
1347                volume.save()
1348
1349            # Return the volume:
1350            return volume
1351
1352        # Indicate no volume was found:
1353        return None
1354
1355    def _add_aliases(self, volume):
1356        """ Try to add all of the external volume references as aliases for
1357            this volume.
1358        """
1359        aliases = self.aliases
1360        volume_name = volume.name
1361        for vname in volume.aliases:
1362            if (vname in aliases) and (volume_name != aliases[vname]):
1363                raise TraitError(
1364                    (
1365                        "Image library error: "
1366                        "Attempt to alias '%s' to '%s' when it is "
1367                        "already aliased to '%s'"
1368                    )
1369                    % (vname, volume_name, aliases[volume_name])
1370                )
1371            aliases[vname] = volume_name
1372
1373    def _duplicate_volume(self, volume_name):
1374        """ Raises a duplicate volume name error.
1375        """
1376        raise TraitError(
1377            (
1378                "Attempted to add an image volume called '%s' when "
1379                "a volume with that name is already defined."
1380            )
1381            % volume_name
1382        )
1383
1384
1385# Create the singleton image object:
1386ImageLibrary = ImageLibrary()
1387