2626
2727from collections import OrderedDict
2828from functools import partial
29+ import json
30+ from hashlib import sha1
2931import os
32+ import posixpath
3033import sys
3134
3235from commoncode import fileutils
36+ from commoncode .fileutils import as_posixpath
3337from commoncode import timeutils
3438
3539from scancode import scans_cache_dir
3640
3741"""
38- Caching scans on disk: A cache of all the scan results .
42+ Cache scan results for a file or directory disk using a file-based cache .
3943
40- Each scan results for a file or directory is cached on disk.
44+ The approach is to cache the scan of a file using these files:
45+ - one "global" file contains a log of all the paths scanned.
46+ - for each file being scanned, we store a file that contains the corresponding file
47+ info data as JSON. This file is named after the hash of the path of a scanned file.
48+ - for each unique file being scanned (e.g. based on its content SHA1), we store a
49+ another JSON file that contains the corresponding scan data. This file is named
50+ after the hash of the scanned file content.
4151
42- The approach is to use to cache:
43- - the results of a scan, excluding file infos keyed by the hash of a scanned file
44- - the file infos, keyed by the path of a scanned file
45-
46- Once a scan is completed, we iterate the caches to output the scan results using this
47- procedure: iterate the cached file infos and for each lookup the scan details in the
48- cached scan results. This iteration is driving the final streaming of results to the
49- output format (e.g. JSON).
52+ Once a scan is completed, we iterate the cache to output the final scan results:
53+ First iterate the global log file to get the paths, from there collect the cached
54+ file info for that file and from the path and file info collect the cached scanned
55+ result. This iterator is then streamed to the final JSON output.
5056
5157Finally once a scan is completed the cache is destroyed to free up disk space.
58+
59+ Internally the cache is organized as a tree of directories named after the first few
60+ characters or a path hash or file hash. This is to avoid having having too many files
61+ per directory that can make some filesystems choke as well as having directories that
62+ are too deep or having file paths that are too long which problematic on some OS.
5263"""
5364
5465# Tracing flags
@@ -69,123 +80,221 @@ def logger_debug(*args):
6980 return logger .debug (' ' .join (isinstance (a , basestring ) and a or repr (a ) for a in args ))
7081
7182
72- class ScanCache (object ):
83+ def get_scans_cache_class (cache_dir = scans_cache_dir ):
84+ """
85+ Return a new persistent cache class configured with a unique storage directory.
86+ """
87+ # create a unique temp directory in cache_dir
88+ fileutils .create_dir (cache_dir )
89+ cache_dir = fileutils .get_temp_dir (cache_dir , prefix = timeutils .time2tstamp () + '-' )
90+ sc = ScanFileCache (cache_dir )
91+ sc .setup ()
92+ return partial (ScanFileCache , cache_dir )
93+
94+
95+ def info_keys (path ):
96+ """
97+ Return a file info cache keys tripple for a path.
98+
99+ For example:
100+ >>> sha1('/w421/scancode-toolkit2').hexdigest()
101+ 'fb87db2bb28e9501ac7fdc4812782118f4c94a0f'
102+ >>> info_keys('/w421/scancode-toolkit2')
103+ ('f', 'b', '87db2bb28e9501ac7fdc4812782118f4c94a0f')
104+ """
105+ return keys_from_hash (sha1 (path ).hexdigest ())
106+
107+
108+ def scan_keys (path , file_info ):
109+ """
110+ Return a scan cache keys tripple for a path and file_info. If the file_info
111+ sha1 is empty (e.g. such as a directory), return a key based on the path instead.
112+ """
113+ sha1_digest = file_info ['sha1' ]
114+ if sha1_digest :
115+ return keys_from_hash (sha1_digest )
116+ else :
117+ # we may eventually store directories, in which case we use the path as a key
118+ # with some extra seed
119+ return info_keys (u'empty hash' + path )
120+
121+
122+ def keys_from_hash (hexdigest ):
123+ """
124+ Return a cache keys triple for a hash hexdigest string.
125+
126+ NOTE: since we use the first character and next two characters as directories, we
127+ create at most 16 dir at the first level and 16 dir at the second level for each
128+ first level directory for a maximum total of 16*16 = 256 directories. For a
129+ million files we would have about 4000 files per directory on average with this
130+ scheme which should keep most file systems happy and avoid some performance
131+ issues when there are too many files in a single directory.
132+
133+ For example:
134+ >>> keys_from_hash('fb87db2bb28e9501ac7fdc4812782118f4c94a0f')
135+ ('f', 'b', '87db2bb28e9501ac7fdc4812782118f4c94a0f')
136+ """
137+ return hexdigest [0 ], hexdigest [1 ], hexdigest [2 :]
138+
139+
140+ def paths_from_keys (base_path , keys ):
141+ """
142+ Return a tuple of (parent dir path, filename) built from a cache keys triple and
143+ a base_directory. Ensure that the parent directory exist.
144+ """
145+ dir1 , dir2 , fname = keys
146+ parent = os .path .join (base_path , dir1 , dir2 )
147+ fileutils .create_dir (parent )
148+ return parent , fname
149+
150+
151+ class ScanFileCache (object ):
73152 """
74- A file-based cache for scan results.
75- This is NOT thread-safe, but is multi-process safe.
153+ A file-based cache for scan results saving results in files and using no locking.
154+ This is NOT thread-safe and NOT multi-process safe but works OK in our context:
155+ we cache the scan for a given file once and read it only a few times.
76156 """
77157 def __init__ (self , cache_dir ):
78158 self .cache_base_dir = cache_dir
79- # subdirs for infos and scans caches
80- self .cache_infos_dir = os .path .join (self .cache_base_dir , 'infos' )
81- self .cache_scans_dir = os .path .join (self .cache_base_dir , 'scans' )
159+ # subdirs for info and scans caches
160+ self .cache_infos_dir = as_posixpath (os .path .join (self .cache_base_dir , 'infos/' ))
161+ self .cache_scans_dir = as_posixpath (os .path .join (self .cache_base_dir , 'scans/' ))
162+ self .cache_files_log = as_posixpath (os .path .join (self .cache_base_dir , 'files_log' ))
82163
83- # workaround for https://github.com/grantjenks/python-diskcache/issues/32
84- from diskcache import Disk
85- class DiskWithNoHighPickleProtocol ( Disk ):
86- "Subclass of diskcache.Disk that always use the lowest pickle protocol."
87- def __init__ ( self , directory , size_threshold , pickle_protocol ):
88- super ( DiskWithNoHighPickleProtocol , self ). __init__ ( directory , size_threshold , pickle_protocol )
89- self ._protocol = 0
164+ def setup ( self ):
165+ """
166+ Setup the cache: must be called at least once globally after cache
167+ initialization.
168+ """
169+ os . makedirs ( self . cache_infos_dir )
170+ os . makedirs ( self .cache_scans_dir )
90171
91- # and finally cache instances
92- from diskcache import Cache
93- self .infos = Cache (self .cache_infos_dir , disk = DiskWithNoHighPickleProtocol )
94- self .scans = Cache (self .cache_scans_dir , disk = DiskWithNoHighPickleProtocol )
172+ @classmethod
173+ def log_file_path (cls , logfile_fd , path ):
174+ """
175+ Log file path in the cache logfile_fd **opened** file descriptor.
176+ """
177+ # we dump the path as JSON, one per line.
178+ # JSON is to avoid any issue with weird file paths/names
179+ logfile_fd .write (json .dumps (path ))
180+ logfile_fd .write ('\n ' )
95181
96- def scan_key (self , path , file_infos ):
182+ def get_cached_info_path (self , path ):
97183 """
98- Return a scan cache key for a path and file_infos .
184+ Return the path where to store a file info in the cache given a path .
99185 """
100- sha1 = file_infos [ 'sha1' ]
101- # we may eventually store directories, in which case we use the path as a key
102- return sha1 or path
186+ keys = info_keys ( path )
187+ paths = paths_from_keys ( self . cache_infos_dir , keys )
188+ return posixpath . join ( * paths )
103189
104- def put_infos (self , path , file_infos ):
190+ def put_info (self , path , file_info ):
105191 """
106- Put file_infos for path in the cache and return True if the file referenced
107- in file_infos has already been scanned or False otherwise.
192+ Put file_info for path in the cache and return True if the file referenced
193+ in file_info has already been scanned or False otherwise.
108194 """
109- self .infos .set (path , file_infos )
110- is_scan_cached = self .scan_key (path , file_infos ) in self .scans
195+ info_path = self .get_cached_info_path (path )
196+ with open (info_path , 'wb' ) as cached_infos :
197+ json .dump (file_info , cached_infos , check_circular = False )
198+ scan_path = self .get_cached_scan_path (path , file_info )
199+ is_scan_cached = os .path .exists (scan_path )
111200 if TRACE :
112- logger_debug ('put_infos:' , 'path:' , path , 'is_scan_cached:' , is_scan_cached , 'file_infos:' , file_infos , '\n ' )
113- logger_debug ('put_infos:' , 'cached_infos:' , self .infos [path ], '\n ' )
201+ logger_debug ('put_infos:' , 'path:' , path , 'is_scan_cached:' , is_scan_cached , 'file_info:' , file_info , '\n ' )
114202 return is_scan_cached
115203
116- def put_scan (self , path , file_infos , scan_result ):
204+ def get_info (self , path ):
117205 """
118- Put scan_result in the cache if not already cached.
206+ Return file info from the cache for a path.
207+ Return None on failure to find the info in the cache.
119208 """
120- scan_key = self .scan_key (path , file_infos )
121- self .scans .add (scan_key , scan_result )
122- if TRACE :
123- logger_debug ('put_scan:' , 'scan_key:' , scan_key , 'file_infos:' , file_infos , 'scan_result:' , scan_result , '\n ' )
124- logger_debug ('put_scan:' , 'cached_infos:' , self .infos [path ], '\n ' )
125- logger_debug ('put_scan:' , 'scan_key:' , scan_key , 'cached_scan:' , self .scans [scan_key ], '\n ' )
209+ info_path = self .get_cached_info_path (path )
210+ if os .path .exists (info_path ):
211+ with open (info_path , 'rb' ) as ci :
212+ return json .load (ci , object_pairs_hook = OrderedDict )
126213
127- def iterate (self , with_infos = True ):
214+ def get_cached_scan_path (self , path , file_info ):
128215 """
129- Yield scan data for all cached scans e.g. the whole cache.
130- If a scan is missing for a given info, an error is appended to scan_errors.
216+ Return the path where to store a scan in the cache given a path and file_info.
131217 """
132- for path in self .infos :
133- file_infos = self .infos [path ]
134- scan_result = OrderedDict (path = path )
135- if with_infos :
136- # infos is always collected but only returnedd if asked:
137- # we flatten these as direct attributes of a file object
138- scan_result .update (file_infos .items ())
139- else :
140- # always include errors even if empty
141- scan_result ['scan_errors' ] = file_infos .get ('scan_errors' , [])
218+ keys = scan_keys (path , file_info )
219+ paths = paths_from_keys (self .cache_scans_dir , keys )
220+ return posixpath .join (* paths )
142221
143- no_scan_details = dict (scan_errors = [
144- ('ERROR: Requested scan details unavailable in cache.' ,
145- 'This is either a bug or processing was aborted with CTRL-C.' )]
146- )
147-
148- scan_key = self .scan_key (path , file_infos )
149- scan_details = self .scans .get (scan_key , no_scan_details )
150- if TRACE :
151- logger_debug ('iterate:' , 'scan_details:' , scan_details , 'for path:' , path , 'scan_key:' , scan_key , '\n ' )
152-
153- # append errors to other top level errors if any
154- scan_errors = scan_details .pop ('scan_errors' , [])
155- scan_result ['scan_errors' ].extend (scan_errors )
222+ def put_scan (self , path , file_info , scan_result ):
223+ """
224+ Put scan_result in the cache if not already cached.
225+ """
226+ scan_path = self .get_cached_scan_path (path , file_info )
227+ if not os .path .exists (scan_path ):
228+ with open (scan_path , 'wb' ) as cached_scan :
229+ json .dump (scan_result , cached_scan , check_circular = False )
230+ if TRACE :
231+ logger_debug ('put_scan:' , 'scan_path:' , scan_path , 'file_info:' , file_info , 'scan_result:' , scan_result , '\n ' )
156232
157- scan_result .update (scan_details )
158- yield scan_result
233+ def get_scan (self , path , file_info ):
234+ """
235+ Return scan results from the cache for a path and file_info.
236+ Return None on failure to find the scan results in the cache.
237+ """
238+ scan_path = self .get_cached_scan_path (path , file_info )
239+ if os .path .exists (scan_path ):
240+ with open (scan_path , 'rb' ) as cs :
241+ return json .load (cs , object_pairs_hook = OrderedDict )
159242
160- def close (self ):
243+ def iterate (self , scan_names ):
161244 """
162- Close the underlying caches.
245+ Yield scan data for all cached scans e.g. the whole cache given a list of
246+ scan names.
247+
248+ The logfile MUST have been closed before calling this method.
163249 """
164- if self .infos :
165- self .infos .close ()
166- if self .scans :
167- self .scans .close ()
250+ with open (self .cache_files_log , 'rb' ) as cached_files :
251+ # iterate the list of (path, (info keys)), one by line
252+ for file_log in cached_files :
253+ path = json .loads (file_log )
254+ file_info = self .get_info (path )
255+
256+ # rare but possible corner case
257+ if file_info is None :
258+ no_info = ('ERROR: file info unavailable in cache: '
259+ 'This is either a bug or processing was aborted with CTRL-C.' )
260+ scan_result = OrderedDict (path = path )
261+ scan_result ['scan_errors' ] = [no_info ]
262+ if TRACE :
263+ logger_debug ('iterate:' , 'scan_result:' , scan_result , 'for path:' , path , '\n ' )
264+ yield scan_result
265+ continue
266+
267+ path = file_info .pop ('path' )
268+ scan_result = OrderedDict (path = path )
269+
270+ if 'infos' in scan_names :
271+ # infos is always collected but only returned if requested
272+ # we flatten these as direct attributes of a file object
273+ scan_result .update (file_info .items ())
274+ if not scan_result .get ('scan_errors' ):
275+ scan_result ['scan_errors' ] = []
276+
277+ # check if we have more than just infos
278+ if ['infos' ] != scan_names :
279+ errors = scan_result ['scan_errors' ]
280+ scan_details = self .get_scan (path , file_info )
281+ if scan_details is None :
282+ no_scan_details = (
283+ 'ERROR: scan details unavailable in cache: '
284+ 'This is either a bug or processing was aborted with CTRL-C.' )
285+ errors .append (no_scan_details )
286+ else :
287+ # append errors to other top level errors if any
288+ scan_errors = scan_details .pop ('scan_errors' , [])
289+ errors .extend (scan_errors )
290+ scan_result .update (scan_details )
291+
292+ if TRACE :
293+ logger_debug ('iterate:' , 'scan_result:' , scan_result , 'for path:' , path , '\n ' )
294+ yield scan_result
168295
169296 def clear (self , * args ):
170297 """
171298 Purge the cache by deleting the corresponding cached data files.
172299 """
173- self .close ()
174300 fileutils .delete (self .cache_base_dir )
175-
176-
177- def get_scans_cache (cache_dir = scans_cache_dir ):
178- """
179- Return a new unique persistent cache instance.
180- """
181- return ScanCache (cache_dir )
182-
183-
184- def get_scans_cache_class (cache_dir = scans_cache_dir ):
185- """
186- Return a new unique persistent cache instance.
187- """
188- fileutils .create_dir (cache_dir )
189- # create a unique temp directory in cache_dir
190- cache_dir = fileutils .get_temp_dir (cache_dir , prefix = timeutils .time2tstamp () + '-' )
191- return partial (ScanCache , cache_dir )
0 commit comments