44# DESCRIPTION: Test automation & report generation for ipynb test files (for BatsPP 1.5.X).
55# BatsPP files are generated by default (stored at ./batspp-only).
66#
7+ # TODO2:
8+ # - use gh.form_path instead of f"{dir}/{file}" (for sake of Windows users).
9+ #
710
811"""
912 Test automation & report generation for BatsPP test files for Bash.
1013 The files can be generated from Jupyter .ipynb files (see ./batspp-only).
1114"""
1215
1316# Standard modules
14- ## TODO: import json
17+ import math
1518
1619# Installed modules
1720import yaml
7174 "Regex for tests to include; ex: 'c.*' for debugging" )
7275SINGLE_STORE = system .getenv_bool ("SINGLE_STORE" , False ,
7376 f"Whether to just use { BATSPP_OUTPUT_STORE } for all store dirs except kcov" )
77+ CLEAN_DEFAULT = system .getenv_bool ("CLEAN_OUTPUT" , not debug .detailed_debugging (),
78+ f"Whether to clean existing output by remove entire directories" )
7479## NOTE: the code needs to be thoroughly revamped (e.g., currently puts .batspp in same place as .bats)
7580if SINGLE_STORE :
7681 BATSPP_STORE = BATS_STORE = TXT_STORE = BATSPP_OUTPUT_STORE
@@ -112,17 +117,21 @@ def main():
112117 BATSPP_SWITCH_ARG = "switch"
113118 FORCE_ARG = "force"
114119 CLEAN_ARG = "clean"
120+ DEFINITIONS_ARG = "definitions"
115121 main_app = Main (
116122 description = __doc__ .format (script = gh .basename (__file__ )).strip ("\n " ),
117123 boolean_options = [
118124 (NO_REPORTS_ARG , "No reports are generated, testfiles are shown" ),
119125 (KCOV_REPORTS_ARG , f"KCOV (HTML based) reports generated, stored at { KCOV_STORE } " ),
120126 (TEXT_REPORTS_ARG , f"Textfile based reports generated, stored at { TXT_STORE } " ),
121- (ALL_REPORTS_ARG , "Generates report for all available testfiles (NOBATSPP testfiles were ignored by default)" ),
127+ (ALL_REPORTS_ARG , "Generates report for all available testfiles (n.b., NOBATSPP testfiles ignored by default)" ),
122128 (FORCE_ARG , "Force running under admin-like account" ),
123129 (CLEAN_ARG , "Remove output from previous runs; *** warning: this removes entire subdirectories" ),
124130 (BATSPP_SWITCH_ARG , "Uses batspp library instead of ../simple_batspp.py script" ),
125131 ],
132+ text_options = [
133+ (DEFINITIONS_ARG , "Script with alias definitions to be sourced" ),
134+ ],
126135 skip_input = False ,
127136 manual_input = False ,
128137 short_options = True ,
@@ -134,10 +143,12 @@ def main():
134143 KCOV_OPTION = main_app .get_parsed_option (KCOV_REPORTS_ARG )
135144 ALL_OPTION = main_app .get_parsed_option (ALL_REPORTS_ARG )
136145 FORCE_OPTION = main_app .get_parsed_option (FORCE_ARG , UNDER_DOCKER )
137- CLEAN_OPTION = main_app .get_parsed_option (CLEAN_ARG )
146+ CLEAN_OPTION = main_app .get_parsed_option (CLEAN_ARG , CLEAN_DEFAULT )
138147 BATSPP_SWITCH_OPTION = main_app .get_parsed_option (BATSPP_SWITCH_ARG )
139148 USE_SIMPLE_BATSPP = (not BATSPP_SWITCH_OPTION )
140- debug .trace_expr (4 , NO_OPTION , TXT_OPTION , KCOV_OPTION , FORCE_OPTION , CLEAN_OPTION , BATSPP_SWITCH_OPTION , USE_SIMPLE_BATSPP )
149+ DEFINITIONS_SCRIPT = main_app .get_parsed_option (DEFINITIONS_ARG )
150+ debug .trace_expr (4 , NO_OPTION , TXT_OPTION , KCOV_OPTION , FORCE_OPTION , CLEAN_OPTION , BATSPP_SWITCH_OPTION , USE_SIMPLE_BATSPP , DEFINITIONS_SCRIPT )
151+ RUN_BATS = (TXT_OPTION or not NO_OPTION )
141152
142153 # Do check for adminstrative user and exit unless --force
143154 is_admin = my_re .search (r"root|admin|adm" , gh .run ("groups" ))
@@ -148,9 +159,9 @@ def main():
148159
149160 # Cleanup up previous rusn
150161 # Warning: 'rm -rf' is a very dangerous command:
151- # it should only be done in temporary directories (i.e. , not under
162+ # it should only be done in temporary directories (e.g. , not under repo)
152163 if CLEAN_OPTION :
153- if not NO_OPTION :
164+ if RUN_BATS :
154165 gh .run (f"rm -rf { BATSPP_STORE } /*" )
155166 gh .run (f"rm -rf { BATS_STORE } /*" )
156167
@@ -167,12 +178,14 @@ def run_batspp(input_file, output_file):
167178 real_output_file = output_file + ".out"
168179 log_file = output_file + ".log"
169180 debug .trace (5 , f"run_batspp{ (input_file , output_file )} " )
181+ source_spec = (f"--source '{ DEFINITIONS_SCRIPT } '" if DEFINITIONS_SCRIPT else "" )
170182 if USE_SIMPLE_BATSPP :
171183 # note: adds sentinels around paragraph segments for simpler parsing;
172- # also uses Bash instead of Bats and copies ./tests files into bats test dir (under temp)
173- run_output = gh .run (f"MATCH_SENTINELS=1 PARA_BLOCKS=1 BASH_EVAL=1 COPY_DIR=1 FORCE_RUN={ FORCE_OPTION } python3 ../simple_batspp.py { input_file } --output { output_file } > { real_output_file } 2> { log_file } " )
184+ # uses Bash instead of Bats (to bypass need for global setup sections)
185+ # and copies ./tests files into bats test dir (under temp).
186+ run_output = gh .run (f"MATCH_SENTINELS=1 PARA_BLOCKS=1 BASH_EVAL=1 COPY_DIR=1 FORCE_RUN={ FORCE_OPTION } python3 ../simple_batspp.py { input_file } --output { output_file } { source_spec } > { real_output_file } 2> { log_file } " )
174187 else :
175- run_output = gh .run (f"batspp { input_file } --save { output_file } 2> { log_file } " )
188+ run_output = gh .run (f"batspp { input_file } --save { output_file } { source_spec } 2> { log_file } " )
176189 debug .code (4 , lambda : gh .run (f"check_errors.perl { log_file } " ))
177190 debug .assertion (not run_output .strip ())
178191 real_output = system .read_file (real_output_file )
@@ -207,15 +220,16 @@ def run_batspp(input_file, output_file):
207220 is_ipynb = file .endswith (IPYNB )
208221 if is_ipynb :
209222 if TEST_REGEX and not my_re .match (fr"{ TEST_REGEX } " , file ):
210- debug .trace (3 , f"Ignoring { file } " )
223+ debug .trace (3 , f"FYI: Ignoring { file } not mathing TEST_REGEX ( { TEST_REGEX } ) " )
211224 continue
212225 if not ALL_OPTION and NOBATSPP in file :
226+ debug .trace (4 , f"FYI: Ignoring NOBATSPP file: { file } " )
213227 print (f"NOBATSPP File Found [{ i } ]: { file } " )
214228 avoid_array .append (file )
215229 avoid_count += 1
216- else :
217- print (f"JUPYTER Testfile Found [{ i } ]: { file } " )
218- ipynb_array .append (file )
230+ continue
231+ print (f"JUPYTER Testfile Found [{ i } ]: { file } " )
232+ ipynb_array .append (file )
219233 i += 1
220234
221235 print (f"\n IPYNB Files Found (Total - NOBATSPP): { i - 1 } - { avoid_count } = { i - avoid_count - 1 } " )
@@ -233,7 +247,7 @@ def run_batspp(input_file, output_file):
233247 gh .basename (testfile .replace (IPYNB , BATSPP )))
234248 print (f"IPYNB TESTFILE [{ i } ]: { testfile } => { batspp_from_ipynb } " )
235249 log_file = f"{ batspp_from_ipynb } .log"
236- gh .run (f"python3 ../jupyter_to_batspp.py { testfile } --output { batspp_from_ipynb } " )
250+ gh .run (f"python3 ../jupyter_to_batspp.py { testfile } --output { batspp_from_ipynb } 2> { log_file } " )
237251 # note: uses call to avoid issue with lambda function argument binding
238252 debug .call (4 , gh .run , f"check_errors.perl { log_file } " , ** {"output" : True })
239253 batspp_array .append (batspp_from_ipynb )
@@ -244,9 +258,14 @@ def run_batspp(input_file, output_file):
244258
245259 # 3) Executing batspp files & storing them as bats
246260 print (f"\n \n ==========BATS GENERATED==========\n " )
261+ # TODO3: rename i => num_test_files, total_count_ok => total_ok_tests, and total_count_total to total_num_tests
247262 i = 1
263+ total_count_ok = 0
264+ total_count_total = 0
265+ total_success_rate = 0
266+ total_num_successful = 0
248267
249- if NO_OPTION :
268+ if not RUN_BATS :
250269 print (f">> SKIPPING BATSPP CHECK (-n ARGUMENT PROVIDED)\n " )
251270 else :
252271 for batsppfile_path in batspp_array :
@@ -266,34 +285,41 @@ def run_batspp(input_file, output_file):
266285 i += 1
267286
268287 if TXT_OPTION :
269- bats_output = run_batspp (batsppfile_path , f"{ BATSPP_OUTPUT_STORE } /{ output_from_batspp } " )
288+ output_from_batspp_path = gh .form_path (BATSPP_OUTPUT_STORE , output_from_batspp )
289+ bats_output = run_batspp (batsppfile_path , output_from_batspp_path )
270290
271291 output_lines = bats_output .splitlines ()
272292 output_lines_filtered = [item for item in output_lines if not item .startswith ("#" )]
273293 debug .trace_expr (5 , output_lines_filtered )
274294 if output_lines_filtered :
275295 # Ignore the line given the number of tests (e.g., "1..5")
276296 header_line = output_lines_filtered .pop (0 )
277- debug .assertion (my_re .search (r"^1\.\.\d+" , header_line ))
297+ debug .trace_expr (5 , header_line )
298+ debug .assertion (my_re .search (r"^1\.\.\d+" , header_line ) or (header_line == "0..0" ),
299+ f"Unexpected header line for { output_from_batspp_path } " )
278300 debug .assertion (len (output_lines_filtered ))
279301
280302 count_ok = len ([item for item in output_lines_filtered if item .startswith ("ok" )])
281303 count_bad = len ([item for item in output_lines_filtered if item .startswith ("not ok" )])
282304 count_total = (count_ok + count_bad )
283- count_success_rate = (round ((count_ok / count_total )* 100 , 2 ) if count_total else 0 )
305+ success_rate = (round ((count_ok / count_total )* 100 , 2 ) if count_total else 0 )
284306 min_score = system .to_float (thresholds .get (ipynb_from_batspp , DEFAULT_MIN_SCORE ))
285- count_success_rate_bool = (count_success_rate >= min_score )
286- debug .trace_expr (4 , min_score , count_ok , count_bad , count_total , count_success_rate , count_success_rate_bool )
287- SUMMARY_TEXT = f"{ count_ok } out of { count_total } successful ({ count_success_rate } %)\n Success: { count_success_rate_bool } "
307+ successful = (success_rate >= min_score )
308+ debug .trace_expr (4 , min_score , count_ok , count_bad , count_total , success_rate , successful )
309+ SUMMARY_TEXT = f"{ count_ok } out of { count_total } successful ({ success_rate } %)\n Success: { successful } "
288310 gh .write_file (f"{ TXT_STORE } /{ txt_from_batspp } " , SUMMARY_TEXT )
289311 print (f"{ test_extensionless } : { SUMMARY_TEXT } " )
312+ total_count_ok += count_ok
313+ total_count_total += count_total
314+ total_success_rate += success_rate
315+ total_num_successful += int (successful )
290316
291317 # Categorizing Tests if they are successful or not
292318 txt_option_JSON = {}
293319 txt_option_JSON ["test_name" ] = test_extensionless
294320 txt_option_JSON ["test_min_score" ] = min_score
295- txt_option_JSON ["test_success_rate" ] = count_success_rate
296- if count_success_rate_bool :
321+ txt_option_JSON ["test_success_rate" ] = success_rate
322+ if successful :
297323 success_test_array .append (txt_option_JSON )
298324 else :
299325 failure_test_array .append (txt_option_JSON )
@@ -326,24 +352,40 @@ def run_batspp(input_file, output_file):
326352 error_testfiles = [tf for tf in ipynb_array if tf not in set_wt ]
327353 faulty_count = ipynb_count - batspp_count
328354
355+ NaN = math .nan
329356 print (f"\n ======================================================" )
330357 print (f"SUMMARY STATISTICS:\n " )
331358 print (f"simple_batspp.py used: { bool (BATSPP_SWITCH_OPTION )} " )
332359 print (f"No. of IPYNB testfiles: { ipynb_count + avoid_count } " )
333- print (f"No. of BATSPP files (generated): { batspp_count if TXT_OPTION or NO_OPTION != 1 else ' NaN' } " )
334- print (f"No. of FAULTY testfiles: { faulty_count if TXT_OPTION or NO_OPTION != 1 else ' NaN' } " )
360+ print (f"No. of BATSPP files (generated): { batspp_count if RUN_BATS else NaN } " )
361+ print (f"No. of FAULTY testfiles: { faulty_count if RUN_BATS else NaN } " )
335362 print (f"No. of AVOIDED testfiles: { avoid_count } " )
363+ print (f"Total no. of good tests: { total_count_ok } " )
364+ print (f"Total no. of individual tests: { total_count_total } " )
365+ # Note: macro-average is mean of success score, whereas micro-average is based on global counts.
366+ # See https://datascience.stackexchange.com/questions/15989/micro-average-vs-macro-average-performance-in-a-multiclass-classification-settin
367+ avg_successful = macro_success_rate = micro_success_rate = NaN
368+ if batspp_count :
369+ avg_successful = total_num_successful / batspp_count * 100
370+ macro_success_rate = total_success_rate / batspp_count
371+ micro_success_rate = total_count_ok / total_count_total * 100
372+ print (f"Total no. files OK w/ threshold: { total_num_successful } " )
373+ print (f"Average no. files OK / threshold: { system .round_num (avg_successful )} %" )
374+ print (f"Macro success score: { system .round_num (macro_success_rate )} %" )
375+ print (f"Micro success score: { system .round_num (micro_success_rate )} %" )
376+ print (" where successful macro is mean of individual scores and micro is global metric" )
336377
337378 print (f"\n FAULTY TESTFILES:" )
338379 if faulty_count == 0 :
339- print ("NaN " )
380+ print ("n/a " )
340381 else :
382+ ## TODO3: what is the intention here (e.g., the '>>')?
341383 for tf in error_testfiles :
342384 print (f">> { tf } " )
343385
344386 print ("\n AVOIDED TESTFILES:" )
345387 if avoid_count == 0 :
346- print ("NaN " )
388+ print ("n/a " )
347389 else :
348390 for tf in avoid_array :
349391 print (f">> { tf } " )
@@ -352,6 +394,7 @@ def run_batspp(input_file, output_file):
352394 print ("\n TEST SUCCESS (--txt ENABLED):" )
353395 print (f"No. of Successful Tests:" , len (success_test_array ))
354396 print (f"No. of Failure Tests:" , len (failure_test_array ))
397+ debug .assertion (len (success_test_array ) == total_num_successful )
355398
356399 def print_test_array (arr ):
357400 """Print summary of test results in ARR"""
@@ -371,7 +414,7 @@ def print_test_array(arr):
371414 print (f"======================================================" )
372415
373416 # Return number of failed tests as statues (i.e., OK if 0 failed)
374- code = len (failure_test_array )
417+ code = ( len (failure_test_array ) if success_test_array else - 1 )
375418 system .exit (status_code = code )
376419
377420# -------------------------------------------------------------------------------
0 commit comments