diff --git a/admin/test/scripts/test_html_report_size.py b/admin/test/scripts/test_html_report_size.py new file mode 100644 index 0000000..2504506 --- /dev/null +++ b/admin/test/scripts/test_html_report_size.py @@ -0,0 +1,241 @@ +"""The HTML report holds back tables above the row limit and summarises oversized index tabs. + +Every row used to go into the artifact's page, and the table script then read them all +back into memory. Measured 2026-09-19 in Chromium on a 16-column messages page: 100,000 +rows were usable after 25.8 s with the browser at 1.7 GB, and at 150,000 the DataTables +call failed with "Maximum call stack size exceeded", left the loading spinner in place and +the browser at 9 GB. The index page copied the run log and the processed files list into +two tabs; a 43 MB list of 229,869 paths opened fast but clicking its tab blocked the page +for over a minute at 12 GB. These tests pin the notice written in place of a large table, +the complete TSV and LAVA output behind it, the index entry that lists it, and the +summarised tabs. +""" +import csv +import inspect +import os +import pathlib +import shutil +import sqlite3 +import sys +import tempfile +import types +import unittest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts import artifact_report, ilapfuncs, lavafuncs, report # pylint: disable=wrong-import-position +from scripts.context import Context # pylint: disable=wrong-import-position + +HEADERS = ('Timestamp', 'Value') + + +def _rows(count): + return [(f'2026-09-19 00:00:{i % 60:02d}', f'value-{i}') for i in range(count)] + + +class TableRowLimitTests(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.report_folder = os.path.join(self.tmpdir, '_HTML', 'Category') + os.makedirs(self.report_folder) + self.saved_limit = artifact_report.HTML_TABLE_ROW_LIMIT + artifact_report.set_html_row_limit(10) + + def tearDown(self): + artifact_report.set_html_row_limit(self.saved_limit) + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _write_page(self, rows, **kwargs): + page = artifact_report.ArtifactHtmlReport('Test Table') + page.start_artifact_report(self.report_folder, 'Test Table', 'a description') + page.add_script() + held_back = page.write_artifact_data_table(HEADERS, _rows(rows), 'a/b.db', **kwargs) + page.end_artifact_report() + with open(os.path.join(self.report_folder, 'Test Table.temphtml'), encoding='utf8') as fh: + return held_back, fh.read() + + def test_a_table_over_the_limit_is_replaced_by_a_notice(self): + held_back, html = self._write_page(11) + self.assertTrue(held_back) + self.assertIn('Total number of entries: 11', html) + self.assertIn('This table has 11 rows, above the 10-row limit', html) + self.assertIn('--html_row_limit 0', html) + self.assertNotIn('value-0', html) + self.assertNotIn('value-'), 10) + + def test_a_limit_of_zero_writes_every_table(self): + artifact_report.set_html_row_limit(0) + held_back, html = self._write_page(11) + self.assertFalse(held_back) + self.assertEqual(html.count('value-'), 11) + + def test_the_notice_names_where_the_complete_rows_are(self): + locations = [artifact_report.LAVA_DATABASE_LOCATION, artifact_report.tsv_export_location('Test Table')] + _, html = self._write_page(11, full_data_locations=locations) + self.assertIn('_lava_artifacts.db', html) + self.assertIn('_TSV Exports/Test Table.tsv', html) + + def test_a_caller_that_names_no_locations_gets_the_general_notice(self): + _, html = self._write_page(11) + self.assertIn("the report's other outputs", html) + + def test_an_explicit_row_limit_overrides_the_module_setting(self): + held_back, _ = self._write_page(11, row_limit=20) + self.assertFalse(held_back) + + +class ArtifactProcessorHeldBackTests(unittest.TestCase): + """A decorated artifact above the limit keeps its TSV and LAVA rows and is listed for the index.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + base = pathlib.Path(self.tmpdir) + self.report_folder = base / '_HTML' / 'Category' + self.report_folder.mkdir(parents=True) + for folder in ('data', 'media', '_HTML/media'): + (base / folder).mkdir(parents=True, exist_ok=True) + lavafuncs.initialize_lava(self.tmpdir, self.tmpdir, 'fs') + Context.set_output_params(types.SimpleNamespace( + media_folder=str(base / 'media'), html_media_folder=str(base / '_HTML' / 'media'), + data_folder=str(base / 'data'))) + self.saved_limit = artifact_report.HTML_TABLE_ROW_LIMIT + artifact_report.set_html_row_limit(5) + del ilapfuncs.html_tables_held_back[:] + + def tearDown(self): + artifact_report.set_html_row_limit(self.saved_limit) + del ilapfuncs.html_tables_held_back[:] + if lavafuncs.lava_db is not None: + try: + lavafuncs.lava_db.close() + except sqlite3.ProgrammingError: + pass + lavafuncs.lava_db = None + lavafuncs.lava_data = None + Context.clear() + Context.set_output_params(None) + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _run_artifact(self, rows): + namespace = { + '__name__': 'fake_big_table', + '__artifacts_v2__': {'big_table': { + 'name': 'Big Table', 'category': 'Category', 'description': 'rows', + 'output_types': ['html', 'tsv', 'lava']}}, + '_rows': _rows, 'HEADERS': HEADERS, + } + exec(f'def big_table(context):\n return HEADERS, _rows({rows}), "a/b.db"\n', namespace) # pylint: disable=exec-used + wrapped = ilapfuncs.artifact_processor(namespace['big_table']) + seeker = types.SimpleNamespace(file_infos={}) + # The wrapper takes (files_found, report_folder, seeker, wrap_text) and, in the + # cores that have one, a timezone offset; hand it as many as it declares. + arguments = [[str(pathlib.Path(self.tmpdir) / 'data' / 'a' / 'b.db')], str(self.report_folder), seeker, False, 'UTC'] + declared = len(inspect.signature(wrapped, follow_wrapped=False).parameters) + wrapped(*arguments[:declared]) + + def test_an_artifact_over_the_limit_keeps_its_tsv_and_lava_rows(self): + self._run_artifact(6) + with open(self.report_folder / 'Big Table.temphtml', encoding='utf8') as fh: + html = fh.read() + self.assertIn('This table has 6 rows, above the 5-row limit', html) + self.assertIn('_TSV Exports/Big Table.tsv', html) + self.assertNotIn('value-0', html) + with open(pathlib.Path(self.tmpdir) / '_TSV Exports' / 'Big Table.tsv', encoding='utf-8-sig') as fh: + tsv_rows = list(csv.reader(fh, delimiter='\t')) + self.assertEqual(len(tsv_rows), 7) # header plus six rows + tables = [r[0] for r in lavafuncs.lava_db.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\'")] + self.assertEqual(len(tables), 1, tables) + lava_rows = lavafuncs.lava_db.execute(f'SELECT COUNT(*) FROM "{tables[0]}"').fetchone()[0] + self.assertEqual(lava_rows, 6) + self.assertEqual(ilapfuncs.html_tables_held_back, [ + {'category': 'Category', 'artifact_name': 'Big Table', 'page': 'Big_Table.html', 'rows': 6}]) + + def test_an_artifact_under_the_limit_is_not_listed(self): + self._run_artifact(5) + with open(self.report_folder / 'Big Table.temphtml', encoding='utf8') as fh: + html = fh.read() + self.assertEqual(html.count('value-'), 5) + self.assertEqual(ilapfuncs.html_tables_held_back, []) + + +class IndexTabTests(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.logs = pathlib.Path(self.tmpdir) / '_HTML' / '_Script_Logs' + self.logs.mkdir(parents=True) + (self.logs / 'DeviceInfo.html').write_text('device info
', encoding='utf8') + self.saved_limit = report.INDEX_TAB_EMBED_LIMIT + report.INDEX_TAB_EMBED_LIMIT = 2000 + del ilapfuncs.html_tables_held_back[:] + + def tearDown(self): + report.INDEX_TAB_EMBED_LIMIT = self.saved_limit + del ilapfuncs.html_tables_held_back[:] + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _index(self, run_log, files_log): + (self.logs / 'Screen_Output.html').write_text(run_log, encoding='utf8') + (self.logs / 'ProcessedFilesLog.html').write_text(files_log, encoding='utf8') + # Cores without a LAVA-only tab take one parameter fewer. + extra = {'lava_only': False} if 'lava_only' in inspect.signature(report.create_index_html).parameters else {} + report.create_index_html(self.tmpdir, 1, '00:00:01', 'zip', '/cases/x.zip', + 'Report Home', {}, '', **extra) + with open(pathlib.Path(self.tmpdir) / '_HTML' / 'index.html', encoding='utf8') as fh: + return fh.read() + + @staticmethod + def _files_log(path_count): + paths = ''.join(f'' for i in range(path_count)) + return ('Extraction/Path selected: /cases/x.zip

' + 'For firstArtifact artifact' + f'' + '' + 'For secondArtifact artifact' + '') + + def test_small_logs_are_copied_into_their_tabs(self): + index = self._index('line one
\nline two
\n', self._files_log(3)) + self.assertIn('line two
', index) + self.assertIn('file-00002.jpg', index) + self.assertNotIn('_Script_Logs/ProcessedFilesLog.html', index) + + def test_a_large_processed_files_list_is_summarised_per_pattern(self): + index = self._index('short
\n', self._files_log(500)) + self.assertIn('href="_Script_Logs/ProcessedFilesLog.html"', index) + self.assertIn('500 files for regex */mobile/*.jpg located at:', index) + self.assertIn('file-00000.jpg', index) + self.assertIn(f'file-{report.INDEX_TAB_PATHS_PER_PATTERN - 1:05d}.jpg', index) + self.assertNotIn(f'file-{report.INDEX_TAB_PATHS_PER_PATTERN:05d}.jpg', index) + self.assertIn(f'{500 - report.INDEX_TAB_PATHS_PER_PATTERN} more in the full list', index) + self.assertIn('No file found for regex */missing/*', index) + self.assertIn('For secondArtifact artifact', index) + self.assertIn('private/one.db', index) + + def test_a_large_run_log_keeps_both_ends(self): + lines = ''.join(f'log line {i}
\n' for i in range(1000)) + index = self._index(lines, self._files_log(1)) + self.assertIn('href="_Script_Logs/Screen_Output.html"', index) + self.assertIn('log line 0
', index) + self.assertIn('log line 999
', index) + self.assertNotIn('log line 500
', index) + self.assertIn('lines not shown here', index) + + def test_held_back_tables_are_listed_on_the_details_tab(self): + ilapfuncs.html_tables_held_back.append( + {'category': 'Chats', 'artifact_name': 'Big Table', 'page': 'Big_Table.html', 'rows': 123456}) + index = self._index('short
\n', self._files_log(1)) + self.assertIn('Big Table (Chats): 123,456 rows', index) + self.assertIn(f'more than {artifact_report.HTML_TABLE_ROW_LIMIT:,} rows', index) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/artifact_report.py b/scripts/artifact_report.py index 9094202..b79ce64 100755 --- a/scripts/artifact_report.py +++ b/scripts/artifact_report.py @@ -4,6 +4,42 @@ from scripts.html_parts import * from scripts.version_info import vleapp_version +# Rows above which write_artifact_data_table leaves the table off the page and writes a +# notice pointing at the LAVA database and the TSV export instead. Measured 2026-09-19 on +# a 16-column messages page in Chromium: 25,000 rows were usable 5.9 s after opening, +# 50,000 in 11.3 s and 100,000 in 25.8 s with the browser holding 1.7 GB for the page; at +# 150,000 the table script failed with "Maximum call stack size exceeded" inside jQuery, +# the loading spinner never cleared and the browser held 9 GB. The main script sets this +# from --html_row_limit; 0 turns the limit off. +HTML_TABLE_ROW_LIMIT = 50000 + +LAVA_DATABASE_LOCATION = ('the LAVA database (_lava_artifacts.db in the report ' + 'folder, opened with LAVA)') + + +def set_html_row_limit(limit): + """Set the row limit for HTML tables; 0 turns it off.""" + global HTML_TABLE_ROW_LIMIT # pylint: disable=global-statement + HTML_TABLE_ROW_LIMIT = max(0, int(limit)) + + +def tsv_export_location(tsv_name): + """Describe an artifact's TSV export for the held-back table notice.""" + return f'the TSV export _TSV Exports/{html.escape(tsv_name)}.tsv' + + +def table_held_back_notice(num_entries, row_limit, full_data_locations=None): + """The notice written in place of a table that exceeds the row limit.""" + if full_data_locations: + where = ' and in '.join(full_data_locations) + else: + where = "the report's other outputs, such as the LAVA database and the TSV export" + return (f'

This table has {num_entries:,} rows, above the ' + f'{row_limit:,}-row limit for HTML report pages, so it is not shown here. The ' + f'complete rows are in {where}. Run with --html_row_limit 0 to write ' + f'it anyway.

') + + class ArtifactHtmlReport: def __init__(self, artifact_name, artifact_category=''): @@ -48,7 +84,9 @@ def write_artifact_data_table( table_responsive=True, table_style='', table_id='dtBasicExample', - html_no_escape=[] + html_no_escape=[], + row_limit=None, + full_data_locations=None ): ''' Writes info about data, then writes the table to html file Parameters @@ -74,6 +112,14 @@ def write_artifact_data_table( table_id : Specify an identifier string, which will be referenced in javascript html_no_escape : if html_escape=True, list of columns not to escape + + row_limit : Rows above which the table is left off the page and a notice + written instead; None uses HTML_TABLE_ROW_LIMIT, 0 means no limit + + full_data_locations : HTML fragments naming where the complete rows are, for + the notice (see LAVA_DATABASE_LOCATION and tsv_export_location) + + Returns True when the table was held back, False when it was written. ''' if (not self.report_file): raise ValueError('Output report file is closed/unavailable!') @@ -90,6 +136,12 @@ def write_artifact_data_table( self.report_file.write('
') + if row_limit is None: + row_limit = HTML_TABLE_ROW_LIMIT + if row_limit and num_entries > row_limit: + self.report_file.write(table_held_back_notice(num_entries, row_limit, full_data_locations)) + return True + if table_responsive: self.report_file.write("
") @@ -121,6 +173,7 @@ def write_artifact_data_table( self.report_file.write('') if table_responsive: self.report_file.write("
") + return False def add_section_heading(self, heading, size='h2'): heading = html.escape(heading) diff --git a/scripts/ilapfuncs.py b/scripts/ilapfuncs.py index 1c809d9..92a4f25 100755 --- a/scripts/ilapfuncs.py +++ b/scripts/ilapfuncs.py @@ -468,6 +468,24 @@ def get_data_list_with_media(media_header_info, data_list): _reported_unsafe_report_names = set() +# Tables left off their HTML page for exceeding artifact_report.HTML_TABLE_ROW_LIMIT, listed +# on the index page so an examiner sees them without opening each artifact. Mutated in +# place only: report.py imports the list itself. +html_tables_held_back = [] + + +def record_html_table_held_back(category, artifact_name, safe_artifact_name, rows): + """Remember a table the HTML report held back, for the index page and the run log.""" + html_tables_held_back.append({ + 'category': category, + 'artifact_name': artifact_name, + # report.generate_report names the final page from the .temphtml file this way + 'page': safe_artifact_name.replace(' ', '_') + '.html', + 'rows': rows, + }) + logfunc(f'{artifact_name}: {rows:,} rows, above the {artifact_report.HTML_TABLE_ROW_LIMIT:,}-row ' + f'limit for HTML pages; the table is left off the page and stays in the other outputs') + def sanitize_report_name(name, kind='name'): """ Replaces path separators in an artifact name or category so it is usable as a file @@ -561,8 +579,17 @@ def wrapper(files_found, report_folder, seeker, wrap_text): report = artifact_report.ArtifactHtmlReport(artifact_name) report.start_artifact_report(report_folder, safe_artifact_name, description) report.add_script() - report.write_artifact_data_table(stripped_headers, html_data_list, source_path, html_no_escape=html_columns) + full_data_locations = [] + if check_output_types('lava', output_types): + full_data_locations.append(artifact_report.LAVA_DATABASE_LOCATION) + if check_output_types('tsv', output_types): + full_data_locations.append(artifact_report.tsv_export_location(safe_artifact_name)) + held_back = report.write_artifact_data_table(stripped_headers, html_data_list, source_path, + html_no_escape=html_columns, + full_data_locations=full_data_locations) report.end_artifact_report() + if held_back: + record_html_table_held_back(category, artifact_name, safe_artifact_name, len(data_list)) if check_output_types('tsv', output_types): tsv(report_folder, stripped_headers, txt_data_list if media_header_info else data_list, safe_artifact_name) @@ -1168,12 +1195,11 @@ def write_lava_only_log(): lava_log.write( """

- The artifacts listed below are likely to return too much data to be viewed \ - in a Web browser, so they have been stored in the '_lava_artifacts.db' \ - SQLite database.
- They are not available from the side bar of the HTML report, but they can \ - currently be viewed with any SQLite database viewer until we release LAVA \ - (LEAPP Artifact Viewer App).

+ The artifacts listed below are declared LAVA only because of the amount of data \ + they return, so their rows are written to the '_lava_artifacts.db' \ + SQLite database and they have no page in the side bar of the HTML report.
+ Open the report folder in LAVA (LEAPP Artifact Viewer App) to review them; \ + any SQLite database viewer can also read the database.

""" ) for category, artifacts in lava_only_artifacts.items(): diff --git a/scripts/report.py b/scripts/report.py index 47cf47c..b1da802 100755 --- a/scripts/report.py +++ b/scripts/report.py @@ -5,6 +5,7 @@ import html import os +import re from pathlib import Path import shutil @@ -14,7 +15,8 @@ body_main_header, body_main_data_title, body_main_trailer, thank_you_note, credits_block, \ individual_contributor, blog_icon, twitter_icon, github_icon, blank_icon, tabs_code, \ tabs_code_with_lava, body_sidebar_dynamic_data_placeholder -from scripts.ilapfuncs import logfunc +from scripts.ilapfuncs import logfunc, html_tables_held_back +import scripts.artifact_report as artifact_report from scripts.version_info import vleapp_version, vleapp_contributors @@ -150,6 +152,72 @@ def copy_no_perm(src, dst): print("_elements folder seems fine. Probably nothing to worry about") +# The run log and the processed files list are copied into two index tabs. Above this +# size a tab shows a summary and a link to the full file instead. Measured 2026-09-19 in +# Chromium with a 43 MB list of 229,869 paths: index.html opened in under a second, but +# clicking the tab laid out 230,000 list items, blocked the page for over a minute and +# took the browser to 12 GB. +INDEX_TAB_EMBED_LIMIT = 2 * 1024 * 1024 +INDEX_TAB_LOG_LINES = 200 # lines kept from each end of a summarised run log +INDEX_TAB_PATHS_PER_PATTERN = 200 # paths kept per search pattern in a summarised files list + +_ARTIFACT_BLOCK = re.compile(r'For (.*?) artifact(.*?)(?=For |\Z)', re.S) +_PATTERN_BLOCK = re.compile( + r'', re.S) +_PATH_ITEM = re.compile(r'', re.S) + + +def _summary_link(path, label, size): + name = os.path.basename(path) + return (f'

The full {label} is {size / 1048576:.1f} MB, above ' + f'the {INDEX_TAB_EMBED_LIMIT // 1048576} MB this page copies in, so this tab shows a ' + f'summary. Open ' + f'{html.escape(name)} for the whole file.

') + + +def run_log_tab_content(path): + """The run log for its index tab: whole when small, both ends and a link when not.""" + size = os.path.getsize(path) + text = get_file_content(path) + if size <= INDEX_TAB_EMBED_LIMIT: + return text + lines = text.split('\n') + if len(lines) <= 2 * INDEX_TAB_LOG_LINES: # long lines, not many: nothing to leave out + return text + head, tail = lines[:INDEX_TAB_LOG_LINES], lines[-INDEX_TAB_LOG_LINES:] + omitted = len(lines) - len(head) - len(tail) + return (_summary_link(path, 'run log', size) + '\n'.join(head) + + f'

{omitted:,} lines not shown here.

' + '\n'.join(tail)) + + +def processed_files_tab_content(path): + """The processed files list for its index tab: whole when small, otherwise each + artifact's patterns with their counts and the first paths, and a link.""" + size = os.path.getsize(path) + text = get_file_content(path) + if size <= INDEX_TAB_EMBED_LIMIT: + return text + out = [_summary_link(path, 'processed files list', size)] + first_block = text.find('For ') + out.append(text[:first_block] if first_block > 0 else '') + for block in _ARTIFACT_BLOCK.finditer(text): + out.append(f'For {block.group(1)} artifact') + for pattern in _PATTERN_BLOCK.finditer(block.group(2)): + missing, count, regex, items = pattern.groups() + if missing is not None: + out.append(f'
  • No file found for regex {missing}
') + continue + paths = _PATH_ITEM.findall(items) + shown = ''.join(f'
  • {p}
' for p in paths[:INDEX_TAB_PATHS_PER_PATTERN]) + more = len(paths) - min(len(paths), INDEX_TAB_PATHS_PER_PATTERN) + if more: + shown += f'
  • {more:,} more in the full list
' + noun = 'file' if count == '1' else 'files' + out.append(f'
  • {count} {noun} for regex {regex} located at:{shown}
') + return ''.join(out) + + def get_file_content(path): """Return UTF-8 text content from the file at the given path.""" f = open(path, 'r', encoding='utf8') @@ -183,7 +251,17 @@ def create_index_html(reportfolderbase, time_in_secs, time_hms, extraction_type, ['Processing time', f'{time_hms} (Total {time_in_secs} seconds)'] ] - tab1_content = generate_key_val_table_without_headings('', case_list) + \ + tab1_content = generate_key_val_table_without_headings('', case_list) + if html_tables_held_back: + held_back_items = ''.join( + f'
  • {html.escape(table["artifact_name"])} ' + f'({html.escape(table["category"])}): {table["rows"]:,} rows
  • ' + for table in html_tables_held_back) + tab1_content += ( + f'
    These tables have more than ' + f'{artifact_report.HTML_TABLE_ROW_LIMIT:,} rows and are left off their HTML pages; each ' + f'page says where the complete rows are.
      {held_back_items}
    ') + tab1_content += \ """

    All dates and times are in UTC unless noted otherwise! @@ -205,11 +283,11 @@ def create_index_html(reportfolderbase, time_in_secs, time_hms, extraction_type, # Get script run log (this will be tab3) script_log_path = os.path.join(reportfolderbase, '_HTML', '_Script_Logs', 'Screen_Output.html') - tab3_content = get_file_content(script_log_path) + tab3_content = run_log_tab_content(script_log_path) # Get processed files list (this will be tab3) processed_files_path = os.path.join(reportfolderbase, '_HTML', '_Script_Logs', 'ProcessedFilesLog.html') - tab4_content = get_file_content(processed_files_path) + tab4_content = processed_files_tab_content(processed_files_path) if lava_only: lava_only_path = os.path.join(reportfolderbase, '_HTML', '_Script_Logs', 'Lava_only_artifacts_log.html') diff --git a/vleapp.py b/vleapp.py index 1e5347f..e52365a 100755 --- a/vleapp.py +++ b/vleapp.py @@ -5,6 +5,7 @@ import pathlib import typing import scripts.report as report +import scripts.artifact_report as artifact_report import traceback import sys @@ -174,6 +175,10 @@ def main(): help=("Generate a text file list of artifact paths. " "This argument is meant to be used alone, without any other arguments.")) parser.add_argument('--custom_output_folder', required=False, action="store", help="Custom name for the output folder") + parser.add_argument('--html_row_limit', required=False, action="store", type=int, + default=artifact_report.HTML_TABLE_ROW_LIMIT, + help="Rows above which an artifact's table is left off its HTML page, which then points at " + "the LAVA database and the TSV export instead (default %(default)s). 0 writes every table.") parser.add_argument('--custom_artifacts_path', required=False, action="store", help="Additional path to load artifacts from (e.g., scripts/alternate_artifacts)") # Check if no arguments were provided @@ -182,6 +187,9 @@ def main(): sys.exit() args = parser.parse_args() + if args.html_row_limit < 0: + parser.error('--html_row_limit must be 0 or a positive number of rows') + artifact_report.set_html_row_limit(args.html_row_limit) loader_paths = [plugin_loader.PLUGINPATH] if args.custom_artifacts_path: