Skip to content

Commit 03f41b0

Browse files
authored
Merge pull request #371 from nexB/267-cli-options-for-timeout-and-max_memory
#267 Add command options for --timeout and --max-memory
2 parents c12948c + 12133fd commit 03f41b0

4 files changed

Lines changed: 40 additions & 123 deletions

File tree

src/scancode/cli.py

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ def wrap(self, timeout=None):
6868
from scancode import __version__ as version
6969

7070
from scancode.interrupt import interruptible
71-
from scancode.interrupt import compute_memory_quota
72-
from scancode.interrupt import compute_timeout
71+
from scancode.interrupt import DEFAULT_TIMEOUT
72+
from scancode.interrupt import DEFAULT_MAX_MEMORY
73+
7374

7475
from scancode import utils
7576

@@ -89,10 +90,6 @@ def wrap(self, timeout=None):
8990
from scancode.api import get_urls
9091

9192

92-
# set a value only for testing scan quotas
93-
TEST_TIMEOUT = 0
94-
TEST_MAX_MEMORY = 0
95-
9693

9794
info_text = '''
9895
ScanCode scans code and other files for origin and license.
@@ -267,29 +264,32 @@ def validate_formats(ctx, param, value):
267264
@click.option('-c', '--copyright', is_flag=True, default=False, help='Scan <input> for copyrights. [default]')
268265
@click.option('-l', '--license', is_flag=True, default=False, help='Scan <input> for licenses. [default]')
269266
@click.option('-p', '--package', is_flag=True, default=False, help='Scan <input> for packages. [default]')
270-
@click.option('--email', is_flag=True, default=False, help='Scan <input> for emails.')
271-
@click.option('--url', is_flag=True, default=False, help='Scan <input> for urls.')
272-
@click.option('-i', '--info', is_flag=True, default=False, help='Scan <input> for files information.')
267+
@click.option('-e', '--email', is_flag=True, default=False, help='Scan <input> for emails.')
268+
@click.option('-u', '--url', is_flag=True, default=False, help='Scan <input> for urls.')
269+
@click.option('-i', '--info', is_flag=True, default=False, help='Include information such as size, type, etc.')
273270
@click.option('--license-score', is_flag=False, default=0, type=int, show_default=True,
274-
help='Matches with scores lower than this score are not returned. A number between 0 and 100.')
271+
help='Do not return license matches with scores lower than this score. A number between 0 and 100.')
275272

276273
@click.option('-f', '--format', is_flag=False, default='json', show_default=True, metavar='<style>',
277274
help=('Set <output_file> format <style> to one of the standard formats: %s '
278275
'or the path to a custom template' % ' or '.join(formats)),
279276
callback=validate_formats)
280277
@click.option('--verbose', is_flag=True, default=False, help='Print verbose file-by-file progress messages.')
281-
@click.option('--quiet', is_flag=True, default=False, help='Do not print any progress message.')
282-
@click.option('-n', '--processes', is_flag=False, default=1, type=int, help='Scan <input> using n parallel processes.')
278+
@click.option('--quiet', is_flag=True, default=False, help='Do not print progress messages.')
279+
@click.option('-n', '--processes', is_flag=False, default=1, type=int, show_default=True, help='Scan <input> using n parallel processes.')
283280

284281
@click.help_option('-h', '--help')
285282
@click.option('--examples', is_flag=True, is_eager=True, callback=print_examples, help=('Show command examples and exit.'))
286283
@click.option('--about', is_flag=True, is_eager=True, callback=print_about, help='Show information about ScanCode and licensing and exit.')
287284
@click.option('--version', is_flag=True, is_eager=True, callback=print_version, help='Show the version and exit.')
288-
@click.option('--diag', is_flag=True, default=False, help='Include detailed diagnnostic messages for scanning errors.')
285+
@click.option('--diag', is_flag=True, default=False, help='Include detailed diagnostic messages in results if there are scanning errors.')
286+
@click.option('--timeout', is_flag=False, default=DEFAULT_TIMEOUT, type=int, show_default=True, help='Stop scanning a file if it takes longer than a timeout in seconds.')
287+
@click.option('--max-memory', is_flag=False, default=DEFAULT_MAX_MEMORY, type=int, show_default=True, help='Stop scanning a file if it its scan requires more than a maximum amount of memory in megabytes.')
289288

290289
def scancode(ctx, input, output_file, copyright, license, package,
291290
email, url, info, license_score, format,
292-
verbose, quiet, processes, diag,
291+
verbose, quiet, processes,
292+
diag, timeout, max_memory,
293293
*args, **kwargs):
294294
"""scan the <input> file or directory for origin clues and license and save results to the <output_file>.
295295
@@ -305,12 +305,9 @@ def scancode(ctx, input, output_file, copyright, license, package,
305305
scans_cache_class = get_scans_cache_class()
306306
try:
307307
to_stdout = output_file == sys.stdout
308-
# for tests only
309-
_timeout = float(os.environ.get('SCANCODE_TEST_TIMEOUT', '0'))
310-
_max_memory = int(os.environ.get('SCANCODE_TEST_MAX_MEMORY', '0'))
311308
files_count, results = scan(input, copyright, license, package, email, url, info, license_score,
312309
verbose, quiet, processes, scans_cache_class, to_stdout,
313-
diag, _timeout, _max_memory)
310+
diag, timeout, max_memory)
314311
save_results(files_count, results, format, input, output_file)
315312
finally:
316313
# cleanup
@@ -322,7 +319,7 @@ def scan(input_path, copyright=True, license=True, package=True,
322319
email=False, url=False, info=True, license_score=0,
323320
verbose=False, quiet=False, processes=1,
324321
scans_cache_class=None, to_stdout=False,
325-
diag=False, _timeout=0, _max_memory=0):
322+
diag=False, timeout=DEFAULT_TIMEOUT, max_memory=DEFAULT_MAX_MEMORY):
326323
"""
327324
Return a tuple of (file_count, indexing_time, scan_results) where
328325
scan_results is an iterable. Run each requested scan proper: each individual file
@@ -372,7 +369,7 @@ def scan(input_path, copyright=True, license=True, package=True,
372369
pool = Pool(processes=processes, maxtasksperchild=1000)
373370
resources = resource_paths(input_path)
374371
scanit = partial(_scanit, scanners=scanners, scans_cache_class=scans_cache_class,
375-
diag=diag, _timeout=_timeout, _max_memory=_max_memory)
372+
diag=diag, timeout=timeout, max_memory=max_memory)
376373
# Using chunksize is documented as much more efficient in the Python doc.
377374
# Yet "1" still provides a better and more progressive feedback.
378375
# With imap_unordered, results are returned as soon as ready and out of order.
@@ -434,7 +431,7 @@ def scan_event(item):
434431
return files_count, cached_scan.iterate(with_infos=info)
435432

436433

437-
def _scanit(paths, scanners, scans_cache_class, diag, _timeout=0, _max_memory=0):
434+
def _scanit(paths, scanners, scans_cache_class, diag, timeout=DEFAULT_TIMEOUT, max_memory=DEFAULT_MAX_MEMORY):
438435
"""
439436
Run scans and cache results on disk. Return a tuple of (success, scanned relative
440437
path) where sucess is True on success, False on error. Note that this is really
@@ -459,10 +456,7 @@ def _scanit(paths, scanners, scans_cache_class, diag, _timeout=0, _max_memory=0)
459456
file_size = infos.get('size', 0)
460457

461458
# quota keyword args for interruptible
462-
# use _timeout or _max_memory for tests if provided or compute these quotas
463-
kwargs = dict()
464-
kwargs['timeout'] = _timeout or compute_timeout(file_size)
465-
kwargs['max_memory'] = _max_memory or compute_memory_quota(file_size)
459+
kwargs = dict(timeout=timeout, max_memory=max_memory)
466460

467461
success, scan_result = interruptible(scans_runner, **kwargs)
468462

src/scancode/interrupt.py

Lines changed: 16 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,10 @@ def wrap(self, timeout=None):
5454

5555
import psutil
5656

57-
58-
MIN_TIMEOUT = 60 # seconds
59-
MAX_TIMEOUT = 600 # seconds
57+
DEFAULT_TIMEOUT = 120 # seconds
6058
RUNTIME_EXCEEDED = 1
6159

62-
MIN_MEMORY = 2 * 1024 * 1024 * 1024 # 2GB
63-
MAX_MEMORY = 4 * 1024 * 1024 * 1024 # 4GB
60+
DEFAULT_MAX_MEMORY = 1000 # megabytes
6461
MEMORY_EXCEEDED = 2
6562

6663

@@ -69,12 +66,12 @@ def interruptible(func, *args, **kwargs):
6966
Call `func` function with `args` arguments and return a tuple of (success, return
7067
value). `func` is invoked through a wrapper and will be interrupted if it does
7168
not return within `timeout` seconds of execution or uses more than 'max_memory`
72-
bytes of memory. `func` returned results should be pickable.
69+
MEGABYTES of memory. `func` returned results should be pickable.
7370
7471
`timeout` in seconds should be provided as a keyword argument.
7572
MIN_TIMEOUT is always enforced even if no timeout keyword is present.
7673
77-
`max_memory` in bytes should be provided as a keyword argument.
74+
`max_memory` in megabytes should be provided as a keyword argument.
7875
If not present a memory quota is not enforced.
7976
8077
Only `args` are passed to `func`, not any `kwargs`.
@@ -87,44 +84,24 @@ def interruptible(func, *args, **kwargs):
8784
item in the tuple is an error message string.
8885
"""
8986

87+
timeout = kwargs.pop('timeout', DEFAULT_TIMEOUT)
88+
max_memory = kwargs.pop('max_memory', DEFAULT_MAX_MEMORY) * 1024 * 1024
89+
9090
# We use a pool of two threads that race to finish against each other:
9191
# - one runs the func proper
9292
# - one runs a loop until a timeout to check memory usage and return when it
9393
# exceeds max_memory or the timeout
9494
# The first thread to complete return its result. The other thread is terminated.
9595

9696
pool = ThreadPool(2)
97+
execution_units = [(func, args,), (time_and_memory_guard, [max_memory, timeout],)]
9798

98-
# our execution units contain at least the the function to run proper
99-
execution_units = [(func, args,)]
100-
101-
only_monitor_timeout = True
102-
timeout = MIN_TIMEOUT
103-
104-
# add timeout only if present
105-
if 'timeout' in kwargs:
106-
timeout = kwargs.pop('timeout', MIN_TIMEOUT)
107-
108-
# add memory quota only if present
109-
if 'max_memory' in kwargs:
110-
max_memory = kwargs.pop('max_memory', MIN_MEMORY)
111-
only_monitor_timeout = False
112-
113-
if only_monitor_timeout:
114-
# monitor using a simple time guard
115-
execution_units.append((time_guard, [timeout],))
116-
else:
117-
# monitor using a combined time + memory guard
118-
execution_units.append((time_and_memory_guard, [max_memory, timeout],))
119-
120-
# submit our threads: whichever finishes first thanks to imap_unordered
121-
# will be returned by the call to next()
99+
# run our threads: whichever finishes first thanks to imap_unordered will be
100+
# returned by the call to next()
122101
threads = pool.imap_unordered(runner, execution_units, chunksize=1)
123102
pool.close()
124-
125103
try:
126-
# always use MAX_TIMEOUT
127-
result = threads.next(MAX_TIMEOUT)
104+
result = threads.next(timeout)
128105
if result == MEMORY_EXCEEDED:
129106
max_mb = megabytes(max_memory)
130107
return False, 'Processing interrupted: excessive memory usage of more than %(max_mb)s.' % locals()
@@ -139,6 +116,7 @@ def interruptible(func, *args, **kwargs):
139116

140117
except KeyboardInterrupt:
141118
return False, 'Processing interrupted with Ctrl-C.'
119+
142120
finally:
143121
# stop processing
144122
pool.terminate()
@@ -153,20 +131,12 @@ def runner(arg):
153131
return func(*args)
154132

155133

156-
def time_guard(timeout):
157-
"""
158-
Return when a timeout has expired.
134+
def time_and_memory_guard(max_memory=DEFAULT_MAX_MEMORY, timeout=DEFAULT_TIMEOUT, interval=2):
159135
"""
160-
sleep(timeout)
161-
return RUNTIME_EXCEEDED
162-
163-
164-
def time_and_memory_guard(max_memory, timeout=MAX_TIMEOUT, interval=2):
165-
"""
166-
Return when max_memory hass been used or when a timeout has expired.
136+
Return when max_memory bytes has been used or when a timeout has expired.
167137
Check memory usage every `interval` seconds during up to `timeout` seconds. Run
168-
until the memory usage in the current process exceeds `max_memory`. If it does,
169-
return `MEMORY_EXCEEDED`. If the memory usage does not go over `max_memory`
138+
until the memory usage in the current process exceeds `max_memory` bytes. If it does,
139+
return `MEMORY_EXCEEDED`. If the memory usage does not go over `max_memory` bytes
170140
within `timeout` seconds, return RUNTIME_EXCEEDED.
171141
"""
172142
process = psutil.Process()
@@ -183,24 +153,6 @@ def time_and_memory_guard(max_memory, timeout=MAX_TIMEOUT, interval=2):
183153
return RUNTIME_EXCEEDED
184154

185155

186-
def compute_timeout(size, extra_sec_per_mb=30):
187-
"""
188-
Return a scan timeout in seconds computed from a file size.
189-
"""
190-
# add extra seconds for each megabyte
191-
timeout = MIN_TIMEOUT + ((size // (1024 * 1024)) * extra_sec_per_mb)
192-
return min([timeout, MAX_TIMEOUT])
193-
194-
195-
def compute_memory_quota(size, extra_ram_multiplier=50):
196-
"""
197-
Return a not-to-exceed maximum memory_quota in bytes computed from a file size.
198-
"""
199-
# add extra quota for each byte of a file bigger than 1MB
200-
memory_quota = MIN_MEMORY + (size * extra_ram_multiplier)
201-
return min([memory_quota, MAX_MEMORY])
202-
203-
204156
def megabytes(n):
205157
"""
206158
Return a megabytes string representation of an `n` number of bytes.

tests/scancode/test_cli.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -368,17 +368,10 @@ def test_scan_works_with_multiple_processes_and_timeouts(monkeypatch):
368368
runner = CliRunner()
369369
result_file = test_env.get_temp_file('json')
370370

371-
patched_environ = dict(
372-
# set small memory quota for test
373-
SCANCODE_TEST_MAX_MEMORY='0', # use default
374-
SCANCODE_TEST_TIMEOUT='1',
375-
)
376-
377371
result = runner.invoke(
378372
cli.scancode,
379-
[ '--copyright', '--license', '--processes', '2', '--format', 'json', test_dir, result_file],
380-
catch_exceptions=True,
381-
env=patched_environ)
373+
[ '--copyright', '--license', '--processes', '2', '--timeout', '1', '--format', 'json', test_dir, result_file],
374+
catch_exceptions=True)
382375

383376
assert result.exit_code == 0
384377
assert 'Scanning done' in result.output
@@ -405,17 +398,10 @@ def test_scan_works_with_multiple_processes_and_memory_quota(monkeypatch):
405398
runner = CliRunner()
406399
result_file = test_env.get_temp_file('json')
407400

408-
patched_environ = dict(
409-
# set small memory quota for test
410-
SCANCODE_TEST_MAX_MEMORY=str(1 * 1024 * 1024),
411-
SCANCODE_TEST_TIMEOUT='0', # use default
412-
)
413-
414401
result = runner.invoke(
415402
cli.scancode,
416-
[ '--copyright', '--license', '--processes', '2', '--format', 'json', test_dir, result_file],
403+
[ '--copyright', '--license', '--processes', '2', '--max-memory', '1', '--format', 'json', test_dir, result_file],
417404
catch_exceptions=True,
418-
env=patched_environ
419405
)
420406

421407
assert result.exit_code == 0

tests/scancode/test_interrupt.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,6 @@
3434
class TestInterrupt(FileBasedTesting):
3535
test_data_dir = os.path.join(os.path.dirname(__file__), 'data')
3636

37-
def test_compute_timeout(self):
38-
assert interrupt.MIN_TIMEOUT == interrupt.compute_timeout(0)
39-
assert interrupt.MIN_TIMEOUT == interrupt.compute_timeout(1000)
40-
assert interrupt.MIN_TIMEOUT + 300 == interrupt.compute_timeout(10 * 1024 * 1024)
41-
assert interrupt.MAX_TIMEOUT == interrupt.compute_timeout(1000 * 1024 * 1024)
42-
43-
def test_compute_memory_quota(self):
44-
assert interrupt.MIN_MEMORY == interrupt.compute_memory_quota(0)
45-
assert interrupt.MIN_MEMORY <= interrupt.compute_memory_quota(1000)
46-
assert interrupt.MIN_MEMORY < interrupt.compute_memory_quota(10 * 1024 * 1024)
47-
assert interrupt.MIN_MEMORY < interrupt.compute_memory_quota(1000 * 1024 * 1024) <= interrupt.MAX_MEMORY
48-
4937
def test_megabytes(self):
5038
assert '12MB' == interrupt.megabytes(12 * 1024 * 1024)
5139
assert '1MB' == interrupt.megabytes(1 * 1024 * 1024)
@@ -57,17 +45,14 @@ def test_memory_guard(self):
5745
# should fail after 2 seconds
5846
assert interrupt.RUNTIME_EXCEEDED == interrupt.time_and_memory_guard(max_memory=1024 * 1024 * 1024 * 1024, timeout=2, interval=1)
5947

60-
def test_time_guard(self):
61-
assert interrupt.RUNTIME_EXCEEDED == interrupt.time_guard(0.1)
62-
6348
def test_interruptible_can_run_function(self):
6449
from time import sleep
6550

6651
def some_long_function(exec_time):
6752
sleep(exec_time)
6853
return 'OK'
6954

70-
result = interrupt.interruptible(some_long_function, 0.01, timeout=10, max_memory=1024 * 1024 * 1024)
55+
result = interrupt.interruptible(some_long_function, 0.01, timeout=10, max_memory=1024)
7156
assert (True, 'OK') == result
7257

7358
def test_interruptible_stops_execution_on_timeout(self):
@@ -89,6 +74,6 @@ def some_hungry_function(exec_time):
8974
_ram = range(1000000)
9075
return 'OK'
9176

92-
success, result = interrupt.interruptible(some_hungry_function, 0.1, timeout=5, max_memory=1000)
77+
success, result = interrupt.interruptible(some_hungry_function, 0.1, timeout=5, max_memory=1)
9378
assert success == False
9479
assert 'Processing interrupted: excessive memory usage of more than' in result

0 commit comments

Comments
 (0)