Skip to content

Commit c3a8509

Browse files
committed
#267 wip: initial, untested and not working prototype
* cache scan results on disk * stream json at the end Signed-off-by: Philippe Ombredanne <pombredanne@nexb.com>
1 parent 3f0a871 commit c3a8509

5 files changed

Lines changed: 213 additions & 35 deletions

File tree

src/scancode/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# Copyright (c) 2015 nexB Inc. and others. All rights reserved.
2+
# Copyright (c) 2016 nexB Inc. and others. All rights reserved.
33
# http://nexb.com and https://github.com/nexB/scancode-toolkit/
44
# The ScanCode software is licensed under the Apache License version 2.0.
55
# Data generated with ScanCode require an acknowledgment.
@@ -23,3 +23,21 @@
2323
# Visit https://github.com/nexB/scancode-toolkit/ for support and download.
2424

2525
__version__ = '2.0.0rc2'
26+
27+
from os.path import dirname
28+
from os.path import abspath
29+
from os.path import getsize
30+
from os.path import getmtime
31+
from os.path import join
32+
from os.path import exists
33+
34+
from commoncode import fileutils
35+
36+
scan_src_dir = abspath(dirname(__file__))
37+
src_dir = dirname(scan_src_dir)
38+
root_dir = dirname(src_dir)
39+
cache_dir = join(root_dir, '.cache')
40+
scans_cache_dir = join(cache_dir, 'scan_results_caches')
41+
42+
if not exists(scans_cache_dir):
43+
fileutils.create_dir(scans_cache_dir)

src/scancode/api.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ def get_licenses(location, min_score=0):
142142
result['matched_rule']['licenses'] = match.rule.licenses
143143
# TODO: add debug details such as matcher
144144
# result['matched_rule']['matcher'] = match.matcher
145-
145+
146146
yield result
147147

148148

149-
def get_file_infos(location):
149+
def get_file_infos(location, as_list=True):
150150
"""
151151
Return a list of dictionaries of informations collected from the file or
152152
directory at location.
@@ -177,7 +177,10 @@ def get_file_infos(location):
177177
infos['is_media'] = is_file and T.is_media or None
178178
infos['is_source'] = is_file and T.is_source or None
179179
infos['is_script'] = is_file and T.is_script or None
180-
return [infos]
180+
if as_list:
181+
return [infos]
182+
else:
183+
return infos
181184

182185

183186
def get_package_infos(location):

src/scancode/cache.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#
2+
# Copyright (c) 2016 nexB Inc. and others. All rights reserved.
3+
# http://nexb.com and https://github.com/nexB/scancode-toolkit/
4+
# The ScanCode software is licensed under the Apache License version 2.0.
5+
# Data generated with ScanCode require an acknowledgment.
6+
# ScanCode is a trademark of nexB Inc.
7+
#
8+
# You may not use this software except in compliance with the License.
9+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
10+
# Unless required by applicable law or agreed to in writing, software distributed
11+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
12+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
# When you publish or redistribute any data created with ScanCode or any ScanCode
16+
# derivative work, you must accompany this data with the following acknowledgment:
17+
#
18+
# Generated with ScanCode and provided on an "AS IS" BASIS, WITHOUT WARRANTIES
19+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
20+
# ScanCode should be considered or used as legal advice. Consult an Attorney
21+
# for any legal advice.
22+
# ScanCode is a free software code scanning tool from nexB Inc. and others.
23+
# Visit https://github.com/nexB/scancode-toolkit/ for support and download.
24+
25+
from __future__ import absolute_import, print_function
26+
27+
import os
28+
29+
from commoncode import fileutils
30+
from commoncode import timeutils
31+
32+
from scancode import scans_cache_dir
33+
from collections import OrderedDict
34+
35+
"""
36+
Caching scans on disk: A cache of all the scan results.
37+
38+
Each scan results for a file or directory is cached on disk.
39+
40+
The approach is to use to cache:
41+
- the results of a scan, excluding file infos keyed by the hash of a scanned file
42+
- the file infos, keyed by the path of a scanned file
43+
44+
Once a scan is completed, we iterate the caches to output the scan results using this
45+
procedure: iterate the cached file infos and for each lookup the scan details in the
46+
cached scan results. This iteration is driving the final streaming of results to the
47+
output format (e.g. JSON).
48+
49+
Finally once a scan is completed the cache is destroyed to free up disk space.
50+
"""
51+
52+
53+
class ScanCache(object):
54+
"""
55+
A file-based cache for scan results.
56+
This is NOT thread-safe, but is multi-process safe.
57+
"""
58+
def __init__(self, cache_dir):
59+
fileutils.create_dir(cache_dir)
60+
61+
# create a unique temp directory in cache_dir
62+
self.cache_base_dir = fileutils.get_temp_dir(cache_dir, prefix=timeutils.time2tstamp()+'-')
63+
64+
# and subdirs for infos and scans caches
65+
self.cache_infos_dir = os.path.join(self.cache_base_dir, 'infos')
66+
fileutils.create_dir(self.cache_infos_dir)
67+
self.cache_scans_dir = os.path.join(self.cache_base_dir, 'scans')
68+
fileutils.create_dir(self.cache_scans_dir)
69+
70+
# and finially cache instances
71+
from diskcache import Cache
72+
self.infos = Cache(self.cache_infos_dir)
73+
self.scans = Cache(self.cache_scans_dir)
74+
75+
def scan_key(self, path, file_infos):
76+
"""
77+
Return a scan cache key for a path and file_infos.
78+
"""
79+
sha1 = file_infos['sha1']
80+
# we may eventually store directories, in which case we use the path as a key
81+
return sha1 or path
82+
83+
def put_infos(self, path, file_infos):
84+
"""
85+
Put file_infos for path in the cache and return True if the file referenced
86+
in file_infos has already been scanned or False otherwise.
87+
"""
88+
self.infos.set(path, file_infos)
89+
return self.scan_key(path, file_infos) in self.scans
90+
91+
def put_scan(self, path, file_infos, scan_result):
92+
"""
93+
Put scan_result in the cache. Also put file_infos in the cache if needed.
94+
"""
95+
self.infos.add(path, file_infos)
96+
scan_key = self.scan_key(path, file_infos)
97+
self.scans.add(scan_key, scan_result)
98+
99+
def iterate(self, with_infos=True):
100+
"""
101+
Return an iterator of scan data for all cached scans e.g. the whole cache.
102+
"""
103+
for path in self.infos:
104+
file_infos = self.infos[path]
105+
scan_result = OrderedDict(path=path)
106+
if with_infos:
107+
# infos is always collected but only returnedd if asked:
108+
# we flatten these as direct attributes of a file object
109+
scan_result.update(file_infos.items())
110+
111+
scan_key = self.scan_key(path, file_infos)
112+
scan_details = self.scans[scan_key]
113+
scan_result.update(scan_details)
114+
yield scan_result
115+
116+
def clear(self, *args):
117+
"""
118+
Purge the cache by deleting the corresponding cached data files.
119+
"""
120+
self.infos.close()
121+
self.scans.close()
122+
fileutils.delete(self.cache_base_dir)
123+
124+
125+
def get_scans_cache():
126+
return ScanCache(cache_dir=scans_cache_dir)

src/scancode/cli.py

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626

2727
from collections import OrderedDict
2828
from functools import partial
29-
import json
3029
import os
3130
import sys
3231
from types import GeneratorType
3332

3433
import click
3534
from click.termui import style
35+
import simplejson as json
3636

3737
from commoncode import ignore
3838
from commoncode import fileutils
@@ -43,6 +43,8 @@
4343
from scancode import __version__ as version
4444
from scancode import utils
4545

46+
from scancode.cache import get_scans_cache
47+
4648
from scancode.format import as_template
4749
from scancode.format import as_html_app
4850
from scancode.format import create_html_app_assets
@@ -260,15 +262,27 @@ def scancode(ctx, input, output_file, copyright, license, package,
260262
license = True
261263
package = True
262264

263-
results = scan(input, copyright, license, package, email, url, info, license_score, verbose, quiet)
264-
save_results(results, format, input, output_file)
265+
scans_cache = get_scans_cache()
266+
try:
267+
files_count, results = scan(input, copyright, license, package, email, url, info, license_score, verbose, quiet, scans_cache)
268+
save_results(files_count, results, format, input, output_file)
269+
finally:
270+
# cleanup
271+
scans_cache.clear()
265272

266273

267274
def scan(input_path, copyright=True, license=True, package=True,
268-
email=False, url=False, info=True, license_score=0, verbose=False, quiet=False):
275+
email=False, url=False, info=True, license_score=0,
276+
verbose=False, quiet=False,
277+
scans_cache=None):
269278
"""
270-
Do the scans proper, return a list of file_results.
279+
Return a tuple of (file_count, scan_results) where scan_results is an iterable.
280+
Run each requested scan proper: each individual file scan is cached on disk to
281+
free memory. Then the whole set of scans is loaded from the cache and streamed at
282+
the end.
271283
"""
284+
assert scans_cache
285+
272286
# save paths to report paths relative to the original input
273287
original_input = fileutils.as_posixpath(input_path)
274288
abs_input = fileutils.as_posixpath(os.path.abspath(os.path.expanduser(input_path)))
@@ -278,16 +292,14 @@ def scan(input_path, copyright=True, license=True, package=True,
278292
# note: "flag and function" expressions return the function if flag is True
279293
# note: the order of the scans matters to show things in logical order
280294
scanners = OrderedDict([
281-
('infos' , info and get_file_infos),
295+
# ('infos' , info and get_file_infos),
282296
('licenses' , license and get_licenses_with_score),
283297
('copyrights' , copyright and get_copyrights),
284298
('packages' , package and get_package_infos),
285299
('emails' , email and get_emails),
286300
('urls' , url and get_urls),
287301
])
288302

289-
file_results = []
290-
291303
# note: we inline progress display functions to close on some args
292304

293305
def scan_start():
@@ -323,48 +335,64 @@ def scan_end():
323335
quiet=quiet
324336
) as progressive_resources:
325337

326-
for resource in progressive_resources:
338+
for files_count, resource in enumerate(progressive_resources):
339+
# actual path of the file being scanned
327340
res = fileutils.as_posixpath(resource)
328-
329341
# fix paths: keep the path as relative to the original input
330342
relative_path = utils.get_relative_path(original_input, abs_input, res)
331-
scan_result = OrderedDict(path=relative_path)
332-
# Should we yield instead?
333-
scan_result.update(scan_one(res, scanners))
334-
file_results.append(scan_result)
335343

336-
# TODO: eventually merge scans for the same files path...
337-
# TODO: fix absolute paths as relative to original input argument...
344+
# always fetch infos and cache.
345+
infos = scan_infos(res)
346+
is_cached = scans_cache.put_infos(relative_path, infos)
338347

339-
return file_results
348+
# Skip other scans if already cached
349+
if is_cached:
350+
continue
351+
scan_result = scan_one(res, scanners)
352+
scans_cache.put_scan(relative_path, infos, scan_result)
353+
files_count += 1
354+
return files_count, scans_cache.iterate(with_infos=info)
355+
356+
357+
def scan_infos(input_file):
358+
"""
359+
Scan one file or directory and return file_infos data.
360+
"""
361+
infos = OrderedDict()
362+
try:
363+
infos = get_file_infos(input_file, as_list=False)
364+
except Exception, e:
365+
raise
366+
# never fail but instead add an error message.
367+
# FIXME: this should not be stored at the individual scan level
368+
return dict(errors=e.message)
369+
return infos
340370

341371

342372
def scan_one(input_file, scans):
343373
"""
344374
Scan one file or directory and return a scanned data, calling every scan in
345375
the `scans` mapping of (scan name -> scan function).
346376
"""
347-
scanned_file = OrderedDict()
377+
scan_result = OrderedDict()
348378
for scan_name, scan_func in scans.items():
349379
if not scan_func:
350380
continue
351381
try:
352382
scan = scan_func(input_file)
383+
# consume generators
353384
if isinstance(scan, GeneratorType):
354385
scan = list(scan)
355-
# this is special and we flatten these as direct attributes of a file object
356-
if scan_name == 'infos':
357-
for file_infos in scan:
358-
scanned_file.update(file_infos.items())
359-
else:
360-
scanned_file[scan_name] = scan
386+
scan_result[scan_name] = scan
361387
except Exception, e:
388+
raise
362389
# never fail but instead add an error message.
363-
scanned_file[scan_name] = {'errors': e.message}
364-
return scanned_file
390+
# FIXME: this should not be stored at the individual scan level
391+
scan_result[scan_name] = {'errors': e.message}
392+
return scan_result
365393

366394

367-
def save_results(scanned_files, format, input, output_file):
395+
def save_results(files_count, scanned_files, format, input, output_file):
368396
"""
369397
Save results to file or screen.
370398
"""
@@ -397,9 +425,10 @@ def save_results(scanned_files, format, input, output_file):
397425
meta = OrderedDict()
398426
meta['scancode_notice'] = acknowledgment_text_json
399427
meta['scancode_version'] = version
400-
meta['files_count'] = len(scanned_files)
428+
meta['files_count'] = files_count
401429
# TODO: add scanning options to meta
402430
meta['files'] = scanned_files
403-
output_file.write(json.dumps(meta, indent=2))
431+
# json.dump(meta, output_file, indent=2)
432+
json.dump(meta, output_file, indent=2 * ' ', iterable_as_array=True)
404433
else:
405434
raise Exception('unknown format')

src/scancode/format.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
from os.path import isfile
3535
from os.path import join
3636

37+
import simplejson as json
38+
3739
from commoncode import fileutils
3840

3941

@@ -130,10 +132,10 @@ def create_html_app_assets(results, output_file):
130132
fileutils.copytree(assets_dir, target_dir)
131133

132134
# write json data
133-
import json
134135
root_path, assets_dir = get_html_app_files_dirs(output_file)
135136
with open(join(root_path, assets_dir, 'data.json'), 'w') as f:
136-
f.write('data=' + json.dumps(results))
137+
f.write('data=')
138+
json.dump(results, f, iterable_as_array=True)
137139

138140
# create help file
139141
with open(join(root_path, assets_dir, 'help.html'), 'w') as f:

0 commit comments

Comments
 (0)