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