-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathsimple_batspp.py
executable file
·1351 lines (1169 loc) · 63.3 KB
/
simple_batspp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/env python
#
# BatsPP: preprocessor for bats unit test
#
# This script processes ands run custom tests using bats-core. It is based
# on example-based testing, using the output from a command as a test:
#
# $ echo $'uno\ndos\ntres' | grep --count o
# 2
#
# NOTE: It is only necessary to have installed bats-core, In particular, there
# is no need for bats-assertions library.
#
# FYI: This version is a simplified version of the original BatsPP facility. It uses some shameless
# hacks enabled via environment variables to work around quirks in the example parsing. For example,
# TEST_FILE=1 MATCH_SENTINELS=1 PARA_BLOCKS=1 python ./simple_batspp.py tests/adhoc-tests.test
#
# - For the regular version of BatsPP, see
# https://github.com/LimaBD/batspp
# - For the Bats[-core] testing framework for Bash, see
# https://github.com/bats-core/bats-core
# - Regex cheatsheet:
# (?:regex) non-capturing group
# (?#comment) comment; used below for labeling regex segments
# *? and +? non-greedy match
# - See https://www.rexegg.com/regex-quickstart.html for comprehensive cheatsheet.
# - See https://regex101.com for a tool to explain regex's.
# - See jupyter_to_batspp.py for conversion utility for Jupyter notebooks.
# - The following directives are recognized:
# Directive Comment
# Continuation Additional setup code
# Global setup Global setup code (e.g., alias definitions).
# Setup Setup code for the test (e.g., temp. file creation)
# Test [name] Name to use for test instead of line number
# Wrapup Optional cleanup code
# - The following environment options can be useful:
# MATCH_SENTINELS Wraps tests inside #Start ... # End to facilitate pattern matching.
# PARA_BLOCKS Tests are paragraphs delimited by blank lines
# BASH_EVAL Use Bash for evaluation instead of bats-core
#................................................................................
# Tips:
# - The parser is regex based so certain contructs confuse it.
# - Use separate blocks for comments following a command.
# [ echo a; # TODO: echo b; ] => [ echo a; ] [ # TODO: echo b; ]
# where the brackets indicate distinct text paragraph blocks or Jupyter cells.
# - Run the Bash-formatted output through shellcheck (e.g., BASH_EVAL=1 OMIT_TRACE=1).
#................................................................................
#
## TODO1:
## - Add option to use bash-compiant syntax for tests; via https://bats-core.readthedocs.io/en/stable/gotchas.html
## function bash_compliant_function_name_as_test_name { # @test
## # your code
## }
## - Issue a warning about tests with '<<END' and other arcane syntax that conflict with
## the .bats file generation.
## - See if way to warn if empty actual output (versus expected) is likely command issue. such
## as based on analysis of stderr. Perhaps there was a problem with aliases added to normalize output.
## - Refine diagnostics for test parsing (e.g., a la trace_pattern_match).
## TODO2:
## - Have option to save each test in a separate bats file: a simple syntax error (e.g., wrong closing quote) can cause the entire test to fail.
## - Track down source of hangup's when evaluating code with a command that gets run in background:
## the function test-N-actual runs OK (i.e., '$ test-N-action'), but it get stuck when accessing the
## result text (e.g., '$ result=$(test-N-actual)'.
## - Add pytest-styles directives like xfail and skip, as well as a way to flag critical tests.
## TODO3:
## - Warn if expecting command and non-comment and non-$ line encounters (e.g., Tom's funky ¢ prompt)
## - Stay in synh with Batspp:
## https://github.com/LimaBD/batspp/tree/main/tests/cases
## - Use gh.form_path consistently (n.b., someday this might run under Window).
## - Weed out Unix-specific file path usages (e.g., f'\tcommand cp -Rp ./. "$test_folder"\n').
## TODO4:
## - Integrate features from similar utilities:
## https://pypi.org/project/docshtest/
## https://github.com/lehmannro/assert.sh
## - Model comment directives after Python's doctest:
## https://docs.python.org/3/library/doctest.html#doctest-directives
## - Likewise, use compatible output conventions like ... and <BLANKLINE>.
## TODO:
## - extend usage guide or docstring.
## - Add some directives in the test or script comments:
## Block of commands used just to setup test and thus without output or if the output should be ignored (e.g., '# Setup').
## Tests that should be evaluated in the same environment as the preceding code (e.g., '# Continuation'). For example, you could have multiple assertions in the same @test function.
## - find regex match excluding indent directly.
## - pretty result.
## - setup for functions tests.
## - multiline commands?.
## - solve comma sanitization, test poc [TODO: what is the issue?]:
## $ BATCH_MODE=1 bash -i -c 'source ../tomohara-aliases.bash; mkdir -p $TMP/test-$$; cd $TMP/test-$$; touch F1.txt F2.list F3.txt F4.list F5.txt; ls | old-count-exts'
## .txt\t3
## .list\t2
## - add a tag to avoid running a certain test (example "# OLD").
## - add option for runnng generated bats code through shellcheck
## This requires that the test definitions be converted to proper bash functions:
## perl -pe 's/^\@test "(.*)"/function $1/;' my-test.bats > my-text.bash
## - add examples below to help clarify processing
"""
BATSPP
This process and run custom tests using bats-core.
You can run tests for aliases and more using the
command line with '$ [command]' followed by the
expected output:
$ echo -e "hello\nworld"
hello
world
$ echo "this is a test" | wc -c
15
Also you can test bash functions:
[functions + args] => [expected]:
fibonacci 9 => "0 1 1 2 3 5 8 13 21 34"
Simple usage:
MATCH_SENTINELS=1 PARA_BLOCKS=1 BASH_EVAL=1 {prog} xyz.batspp > xyz.log 2>&1
"""
# Standard packages
from collections import namedtuple
import os
import re
import random
# Local packages
from mezcla.main import Main
from mezcla.my_regex import my_re
from mezcla import system
from mezcla import debug
from mezcla import glue_helpers as gh
#-------------------------------------------------------------------------------
# Command-line labels constants
TESTFILE = 'testfile' # target test path
OUTPUT = 'output' # output BATS test
VERBOSE = 'verbose' # show verbose debug
SOURCE = 'source' # source another file
JUPYTER = 'jupyter' # run jupyter conversion (i.e., ipynb to .batspp)
FORCE = 'force' # run bats even if admin-like user
# Environment options
TMP = system.getenv_text("TMP", "/tmp",
"Temporary directory")
TEMP_DIR_DEFAULT = (gh.TEMP_BASE or gh.form_path(TMP, f"batspp-{os.getpid()}"))
TEMP_DIR = system.getenv_text("TEMP_DIR", TEMP_DIR_DEFAULT,
"Temporary directory to use for tests")
EVAL_LOG = system.getenv_value("EVAL_LOG", None,
"Override for temp-file based evaluation log file")
COPY_DIR = system.getenv_bool("COPY_DIR", False,
"Copy current directory to temp. dir for input files, etc.")
FORCE_RUN = system.getenv_bool("FORCE_RUN", False,
"Force execution of the run even if admin-like user, etc.")
# Options to work around quirks with Batspp
PREPROCESS_BATSPP = system.getenv_bool("PREPROCESS_BATSPP", False,
"Preprocess .batspp format file, removing line continuations")
MATCH_SENTINELS = system.getenv_bool("MATCH_SENTINELS", False,
"Include test comment header and trailer in match")
PARA_BLOCKS = system.getenv_bool("PARA_BLOCKS", False,
"Test definitions within perl-style paragraphs--\n\n ends")
# Other useful options
TRACE_MATCHING = system.getenv_bool("TRACE_MATCHING", False,
"Trace out test matching for text")
RANDOM_ID = system.getenv_bool("RANDOM_ID", False,
"Use random ID's for tests")
OMIT_PATH = system.getenv_bool("OMIT_PATH", False,
"Omit PATH spec. for directory of .Batspp file")
BATS_OPTIONS = system.getenv_text("BATS_OPTIONS", " ",
"Options for bats command such as --pretty")
SKIP_BATS = system.getenv_bool("SKIP_BATS", False,
"Do not run the bats test script")
OMIT_TRACE = system.getenv_bool("OMIT_TRACE", False,
"Omit actual/expected trace from bats file")
OMIT_MISC = system.getenv_bool("OMIT_MISC", False,
"Omit miscellaenous/obsolete bats stuff such as diagnostic code in test")
TEST_FILE = system.getenv_bool("TEST_FILE", False,
"Treat input as example-based test file, not a bash script")
BASH_EVAL = system.getenv_bool("BASH_EVAL", False,
"Evaluate tests via bash rather than bats: provides quicker results and global context")
BASH_TRACE = system.getenv_bool("BASH_TRACE", False,
"Trace commands during test evaluation")
MAX_ESCAPED_LEN = system.getenv_int("MAX_ESCAPED_LEN", 64,
"Maximum length for escaped actual vs. expected")
GLOBAL_TEST_DIR = system.getenv_bool("GLOBAL_TEST_DIR", False,
"Use single directory for tests")
KEEP_OUTER_QUOTES = system.getenv_bool("KEEP_OUTER_QUOTES", False,
"Retain outer quotation characters in output")
#
# Shameless hacks
## TEST: GLOBAL_SETUP = system.getenv_text("GLOBAL_SETUP", " ",
## "Global setup bash snippet")
EXTRACT_SETUP = system.getenv_bool("EXTRACT_SETUP", False,
"Extract setup based on '# Setup' comment in entire match")
AUGMENT_COMMANDS = system.getenv_bool("AUGMENT_COMMANDS", False,
"Add commands missing from setup or actual from entire match")
DISABLE_ALIASES = system.getenv_bool("DISABLE_ALIASES", False,
"Disable alias expansion")
MERGE_CONTINUATION = system.getenv_bool("MERGE_CONTINUATION", False,
"Merge function or backslash continuations in expected with actual")
IGNORE_ALL_COMMENTS = system.getenv_bool("IGNORE_ALL_COMMENTS", False,
"Strip all comments from input--blocks without commands or output")
STRIP_COMMENTS = system.getenv_bool("STRIP_COMMENTS", False,
"Strip comments from expected output")
ALLOW_COMMENTS = system.getenv_bool("ALLOW_COMMENTS", False,
"Allow comments in expected output")
NORMALIZE_WHITESPACE = system.getenv_bool("NORMALIZE_WHITESPACE", False,
"Convert non-newline whitespace to space")
IGNORE_SETUP_OUTPUT = system.getenv_bool("IGNORE_SETUP_OUTPUT", False,
"Ignore output from setup, wrapup, etc. sections")
FILTER_SHELLCHECK = system.getenv_bool("FILTER_SHELLCHECK", False,
description="Add shellcheck warning filters")
OLD_ACTUAL_EVAL = system.getenv_bool("OLD_ACTUAL_EVAL", False,
description="Use actual output evaluation via implicit subshell")
# Flags
USE_INDENT_PATTERN = system.getenv_bool("USE_INDENT_PATTERN", False,
"Use old regex for indentation")
# Some constants
## Bruno: can you explain this pattern?
INDENT_PATTERN = r'^[^\w\$\(\n\{\}]' # note: modified in __process_tests
BATSPP_EXTENSION = '.batspp'
# Trace levels usually go from from 6 .. 9, but from 4 .. 7 for tom, tohara, etc.
USER = system.getenv_text("USER", "user",
"User name")
T6_DEFAULT = (6 if not USER.startswith("to") else 4)
T6 = system.getenv_int("T6", T6_DEFAULT,
"Trace level to use for T6")
T7 = (T6 + 1)
T8 = (T7 + 1)
T9 = (T8 + 1)
## TEMP
# pylint: disable=f-string-without-interpolation
#-------------------------------------------------------------------------------
# Utility functions
def merge_continuation(actual, expected):
"""Merge EXPECTED into ACTUAL if line continuation (e.g., function definition)
Note: returns tuple with new actual and expected
>>> merge_continuation(
... '''
... function f {
... ''',
... '''
... x=1
... }
...
... 123
... ''')
(
'''
function f {
x=1
}
''',
'''
123
''')
"""
## ex: merge_continuation("function g () {}\n", "123\n") => ("function g () {}\n", "123\n") # no change
actual_lines = actual.split("\n")
expected_lines = expected.split("\n")
# Check for open function definition or trailing backslash at end of actual section
line_continuation = False
function_continuation = False
for s in range(len(actual_lines) - 1, -1, -1):
actual_line = actual_lines[s]
if re.search(r"function.*{\s*$", actual_line):
debug.trace(T6, f"Open function definition at actual line {s + 1}: {actual_line!r}")
function_continuation = True
break
if re.search(r"[^\\]\\(\n?)$", actual_line):
## Lorenzo: I think the string bellow was supposed to be an Fstring because {s + 1} doesn't make much sense if not
debug.trace(T6, "Line continuation at actual line {s + 1}: {actual_line!r}")
line_continuation = True
break
debug.trace(T9, f"Non-continuation at actual line {s + 1}: {actual_line!r}")
# If during continuation, merge lines until no longer at a continuation
for c, expected_line in enumerate(expected_lines):
if line_continuation or function_continuation:
debug.trace(T8, f"Merging expected line {c + 1} with actual: {expected_line!r}")
actual_lines.append(expected_line)
expected_lines = expected_lines[1:]
else:
break
line_continuation = re.search(r"[^\\]\\(\n?)", expected_line)
function_continuation = (function_continuation and (not re.search(r"^\s+\}", expected_line)))
debug.trace_expr(T7, expected_line, line_continuation, function_continuation)
# Sanity check
new_actual = "\n".join(actual_lines)
new_expected = "\n".join(expected_lines)
debug.assertion((new_actual + new_expected) == (actual + expected))
new = ("new" if (new_actual != actual) else "old")
debug.trace(T9, f"merge_continuation({actual!r}, {expected!r})) => {new} ({new_actual!r}, {new_expected!r})")
return (new_actual, new_expected)
def preprocess_batspp(contents):
"""Preprocess the file CONTENTS in the .batspp format (e.g., remove line continuations)
>>> preprocess_batspp(r'''
... function fu {\
... echo "fu"; \
... }
... ''')
'''function fu { echo "fu"; }'''
"""
# ex: preprocess_batspp('function bar { echo "bar"; }') => 'function bar { echo "bar"; }' # no change
# Note: this removes line continuations for sake of simpler parsing (e.g., avoid merge_continuation)
debug.trace(T6, "preprocess_batspp(_)")
## Bruno: can you fix this regex replacement?
## TODO: new_contents = my_re.sub(r"^(.*[^\\])\\(\n)$", r"\1\n", contents, flags=re.MULTILINE)
lines = contents.split("\n")
s = 0
while (s < len(lines)):
line = lines[s]
debug.trace(T7, f"checking line {s + 1}: {line}")
if ((line is not None) and line.endswith("\\") and (len(line) > 1) and (line[-2] != "\\") and (s < len(lines))):
debug.trace(T6, f"joining lines {s + 1} and {s + 2}")
lines[s] = line[:-1] + lines[s + 1]
for j in range(s + 1, len(lines) - 1):
lines[j] = lines[j + 1]
lines[-1] = None
else:
s += 1
new_contents = "\n".join(l for l in lines if (l is not None))
new = ("new" if (new_contents != contents) else "old")
debug.trace(T8, f"preprocess_batspp({contents!r}) => {new} {new_contents!r}")
debug.trace(T8, f"\tlen(c)={len(contents)}; len(nc)={len(new_contents)}")
debug.assertion(len(new_contents) <= len(contents))
return new_contents
#-------------------------------------------------------------------------------
TEST_FIELD_NAMES = ["entire", "title", "setup", "actual", "expected"]
TestFieldTypes = namedtuple("TestFieldTypes", TEST_FIELD_NAMES)
class Batspp(Main):
"""This process and run custom tests using bats-core"""
# Class-level member variables for arguments (avoids need for class constructor)
testfile = ''
output = ''
source = ''
verbose = False
force = FORCE_RUN
jupyter = False
# Global States
is_test_file = None
file_content = ''
eval_prog = ("bats" if not BASH_EVAL else "bash")
bats_content = f'#!/usr/bin/env {eval_prog}\n\n'
if BASH_TRACE:
bats_content += "# enable command tracing\nset -o xtrace\n\n"
def setup(self):
"""Process arguments"""
# Check the command-line options
self.testfile = self.get_parsed_argument(TESTFILE, "")
self.output = self.get_parsed_argument(OUTPUT, "")
self.source = self.get_parsed_argument(SOURCE, "")
self.jupyter = self.get_parsed_option(JUPYTER, self.jupyter)
self.force = self.get_parsed_option(FORCE, self.force)
self.verbose = self.get_parsed_option(VERBOSE, system.getenv_bool("VERBOSE"))
debug.trace(T7, (f'batspp - testfile: {self.testfile}, '
f'output: {self.output}, '
f'source: {self.source}, '
f'force: {self.force}, '
f'jupyter: {self.jupyter}, '
f'verbose: {self.verbose}'))
debug.trace_object(T8, self, label=f"{self.__class__.__name__} instance")
def run_main_step(self):
"""Process main script"""
# TODO4: remove temp files unless debugging
debug.trace_object(T7, f"{self.__class__.__name__}.run_main_step()")
# Optionally convert Jupyter notebook (.ipynb) to BatsPP file (.batspp)
if self.jupyter:
debug.assertion(self.testfile.endswith("ipynb"))
temp_batspp_file = self.temp_file + BATSPP_EXTENSION
log_file = temp_batspp_file + ".log"
gh.run(f"jupyter_to_batspp.py --output '{temp_batspp_file}' '{self.testfile}' 2> {log_file}")
## DEBUG (tracking down TEMP_FILE issue):
## gh.run(f"cp -v {temp_batspp_file} /tmp")
## debug.assertion(system.file_exists(gh.form_path("/tmp", gh.basename(temp_batspp_file, temp_batspp_file))))
self.testfile = temp_batspp_file
# Check if is test of shell script file
self.is_test_file = (TEST_FILE or self.testfile.endswith(BATSPP_EXTENSION))
debug.trace(T7, f'batspp - {self.testfile} is a test file (not shell script): {self.is_test_file}')
## TODO: debug.assertion(self.is_test_file == Batspp.is_test_file)
debug.assertion(self.is_test_file == Batspp.is_test_file, assert_level=T8)
# TEMP: make sure global same as instance
Batspp.is_test_file = self.is_test_file
# Read file content
self.file_content = system.read_file(self.testfile)
if (self.is_test_file and PREPROCESS_BATSPP):
self.file_content = preprocess_batspp(self.file_content)
# Check if ends with newline
if not self.file_content.endswith('\n\n'):
self.file_content += '\n'
self.__process_setup()
self.__process_teardown()
self.__process_tests()
# Set Bats filename
if self.output:
batsfile = self.output
if batsfile.endswith('/'):
## TODO: fixme (e.g., filename '##.batspp')-- ex via glue_helpers.remove_extension
name = re.search(r"\/(\w+)\.", batsfile).group(0)
batsfile += f'{name}.bats'
else:
batsfile = self.temp_file
# Save Bats file
system.write_file(batsfile, self.bats_content)
# Add execution permission to directly run the result test file
if self.output:
gh.run(f'chmod +x {batsfile}')
# Run unless adminstrative user and --force not
skip_bats = SKIP_BATS
if not skip_bats:
is_admin = my_re.search(r"root|admin|adm", gh.run("groups"))
if is_admin:
if not self.force:
system.exit("Error: running bats under admin-like account requires --{force} option", force=FORCE)
system.print_error("FYI: not recommended to run under admin-like account (to avoid inadvertant deletions, etc..)")
if not SKIP_BATS:
## TODO2: include time out to account for hanging tests
debug.trace(T7, f'batspp - running test {self.temp_file}')
debug.assertion(not (BASH_EVAL and BATS_OPTIONS.strip()))
eval_prog = ("bats" if not BASH_EVAL else "bash")
# note: uses empty stdin in case of buggy tests (to avoid hangup);
# uses .eval.log to avoid conflict with batspp_report.py
eval_log = (EVAL_LOG if EVAL_LOG else (self.temp_file + ".eval.log"))
bats_output = gh.run(f'{eval_prog} {BATS_OPTIONS} {batsfile} < /dev/null 2> {eval_log}')
print(bats_output)
system.print_stderr(debug.call(4, gh.run, f"check_errors.perl {eval_log}") or "")
debug.assertion(not my_re.search(r"^0 tests", bats_output, re.MULTILINE))
def __process_setup(self):
"""Process tests setup"""
debug.trace(T7, f'batspp - processing setup')
# NOTE: files are loaded globally to
# to avoid problems with functions
# commands.
# Make executables ./tests/../ visible to PATH
#
# This is usefull then the file is specially for testing.
#
# The structure should be:
# project
# ├ script.bash
# └ tests/test_script.batspp
if not OMIT_PATH:
self.bats_content += ('# Make executables ./tests/../ visible to PATH\n'
f'PATH="{gh.dir_path(gh.real_path(self.testfile))}/../:$PATH"\n\n')
# Enable aliases unless explicitly disabled
enable_aliases = (not DISABLE_ALIASES)
if not enable_aliases:
all_content = (self.file_content + (system.read_file(self.source) if self.source else ""))
debug.assertion(re.search(r"(alias \S+ =)|(function \S+ \(\) \{)", all_content, flags=my_re.VERBOSE))
if enable_aliases:
self.bats_content += ('# Enable aliases\n'
'shopt -s expand_aliases\n\n')
# Source Files
# Notes: This is used to enable aliases globally. Unfortunately, it gets reloaded for each test
# and can take a long time to run under bats-core, which can be slow to begin with (e.g., each
# test gets run in a separate process).
# See https://bats-core.readthedocs.io/en/stable/faq.html#how-can-i-include-my-own-sh-files-for-testing.
# - The setup_file function mentioned there is not helpful as that doesn't allow for aliases.
if (self.source or (not self.is_test_file)):
self.bats_content += ('# Source files\n')
num_sourced = 0
if not self.is_test_file:
self.bats_content += f'source {gh.real_path(self.testfile)} || true\n'
num_sourced += 1
if self.source:
self.bats_content += f'source {gh.real_path(self.source)} || true\n'
num_sourced += 1
if num_sourced:
self.bats_content += '\n'
# Global variables
if IGNORE_SETUP_OUTPUT:
self.bats_content += ("# Global bookkeeping variables\n" +
"test_output_ignored=0\n" +
"num_ignored_tests=0\n" +
"\n")
# Miscellaneous stuff
if FILTER_SHELLCHECK:
self.bats_content += ("# Code linting support\n" +
"# Selectively ignores following shellcheck warnings:\n" +
"# SC2016: Expressions don't expand in single quotes\n" +
"# SC2028: echo may not expand escape sequences\n" +
"\n")
# pylint: disable=no-self-use
def __process_teardown(self):
"""Process teardown"""
debug.trace(T7, f'batspp - processing teardown')
# WORK-IN-PROGRESS
def __process_tests(self):
"""Process tests"""
debug.trace(T7, f'batspp - processing tests')
# Tests with simple indentation (i.e. #) are ignored on batspp files.
# Tests with double indentation (i.e. ##) are ignored on shell scripts.
global INDENT_PATTERN
if self.is_test_file:
## Note: The {0} causes the following indent pattern to be ignored:
## [^\w\$\(\n\{\}]
INDENT_PATTERN += r'{0}'
## TODO: INDENT_PATTERN += "[^#]"
else:
INDENT_PATTERN += r'?'
INDENT_PATTERN += r'\s*'
command_tests = CommandTests(verbose_debug=self.verbose)
function_tests = FunctionTests(verbose_debug=self.verbose)
# TODO: add is_test_file to constructor; simplify class inter-dependencies
command_tests.is_test_file = self.is_test_file
function_tests.is_test_file = self.is_test_file
# Do the test extraction, optionally removing regular content from bash scripts (i.e., extract comments and empty lines)
file_content = self.file_content
JUST_COMMENTS = system.getenv_bool("JUST_COMMENTS", (not self.is_test_file),
"Strip non-comments from bash input")
if JUST_COMMENTS:
# Note: This simplifies pattern matching for text extraction, such as in helping
# to avoid the expected output field from incorporating extraneous content.
# TODO: allow for here-documents with comments and other special cases
file_content = ""
for line in self.file_content.splitlines():
if not my_re.search("^[^#].*$", line):
file_content += line + "\n"
debug.trace(T8, f"content after non-comment stripping:\n\t{file_content!r}")
system.write_file(gh.form_path(TMP, "file_content.list"), file_content)
#
all_test_ids = []
content, ids = command_tests.get_bats_tests(file_content)
self.bats_content += content
all_test_ids += ids
if not self.is_test_file:
# TODO: sort combined set of tests based on file offset to make order more intuitive
content, ids = function_tests.get_bats_tests(file_content)
self.bats_content += content
all_test_ids += ids
# Generate optional code to evaluate tests directly via Bash
if BASH_EVAL:
self.bats_content += 'n=0\nbad=0\n'
self.bats_content += (
'function run-test {\n' +
' local id="$1"\n' +
(' echo Running test "$id"\n' if BASH_TRACE else '') +
' let n++\n' +
' result="ok"\n' +
' eval "$id"; if [ $? -ne 0 ]; then let bad++; result="not ok"; fi\n' +
' echo "$result $n $id"\n' +
(' if [ "$test_output_ignored" = "1" ]; then echo test ignored; fi\n' if IGNORE_SETUP_OUTPUT else '') +
' }\n'
)
self.bats_content += f'tests=({" ".join(all_test_ids)}); echo "1..${{#tests[@]}}"\n'
self.bats_content += 'for id in "${tests[@]}"; do run-test "$id"; done\n'
self.bats_content += 'echo ""\n'
## TODO2: output num ignored (e.g., setup/wrapup code)
ignored_spec = (', $num_ignored_tests ignored' if IGNORE_SETUP_OUTPUT else '')
self.bats_content += f'echo "$n tests, $bad failure(s){ignored_spec}"\n'
#-------------------------------------------------------------------------------
# Global test number
_test_num = 0
class CustomTestsToBats:
"""Base class to extract and process tests"""
def __init__(self, patterns, re_flags=0, verbose_debug=False):
self._verbose = verbose_debug
self._re_flags = re_flags
assert(isinstance(patterns, list) and not isinstance(patterns, str))
self._patterns = None
self._assert_equals = True
self._setup_funct = None
self._test_id = self.next_id()
self._indent_used = None
self.is_test_file = None
self.num_tests = 0
# Add optional header and trailer patterns
self._patterns = patterns
if MATCH_SENTINELS:
# notes: This matches '# Start' as well as comments before title, which is
# done in order to capture '# Setup' comments. Uses non-greedy matches to ignore test name.
header = r'(?#header )(?:# *Start[^\n]*\n(?:#[^\n]*\n)*?)'
## TODO: trailer = r'(?#trailer )(?:# *End[^\n]*\n)'
trailer = r'(?#trailer )'
self._patterns = [header] + patterns + [trailer]
debug.trace_object(T8, self, label=f"{self.__class__.__name__} instance")
def next_id(self):
"""Return next ID for test"""
global _test_num
_test_num += 1
test_id = str(random.randint(1, 999999) if RANDOM_ID else _test_num)
debug.trace(T6, f"next_id() => {test_id}; self={self}")
return test_id
def _first_process(self, match):
"""First process after match, format matched results into [title, setup, actual, expected]"""
# NOTE: this must be overriden.
# TODO: raise NotImplementedError()
# NOTE: the returned values must be like:
result = TestFieldTypes._fields
debug.trace(T7, f'batspp (test {self._test_id}) - CustomTestsToBats._first_process({match}) => {result}')
return result
def _preprocess_field(self, field):
"""Preprocess match result FIELD"""
# Remove comment indicators
field = my_re.sub(r'^\s*\#\s+(Actual|Continuation|End|Global.Setup|Setup|Start|Wrapup)\s*\n', '', field,
flags=re.MULTILINE|re.IGNORECASE)
# Remove indent
## TODO?: if USE_INDENT_PATTERN and not Batspp.is_test_file:
## OLD:
if not Batspp.is_test_file:
field = my_re.sub(fr'^{self._indent_used}', '', field, flags=my_re.MULTILINE)
return field
def _preprocess_command(self, field):
"""Preprocess command FIELD"""
in_field = field
field = self._preprocess_field(field)
# Remove comments (n.b., needs to be done after comment indicators checked)
if STRIP_COMMENTS:
field = my_re.sub(r'^\s*\#.*\n', '', field, flags=re.MULTILINE)
## TODO2: debug.trace(T8, f"_preprocess_command({in_field!r}) == {field!r}")
debug.trace(5, f"_preprocess_command({in_field!r}) == {field!r}")
return field
def _preprocess_output(self, field):
"""Preprocess output FIELD"""
in_field = field
field = self._preprocess_field(field)
# Strip whitespaces
# TODO: make this optional for expected output field
field = field.strip()
# Remove comments (n.b., needs to be done after comment indicators checked
if STRIP_COMMENTS:
debug.trace(T6, "FYI: stripping comments in output field")
field = my_re.sub(r'^\s*\#.*\n', '', field, flags=re.MULTILINE)
elif (my_re.search(r'^\s*\#.*\n', field, flags=re.MULTILINE) and not ALLOW_COMMENTS):
debug.trace(4, f"Error: comment in output field: {field!r}")
# Remove initial and trailing quotes
## OLD: field = my_re.sub(f'^(\"|\')(.*)(\"|\')$', r'\2', field)
if not KEEP_OUTER_QUOTES:
field = my_re.sub(r'^(\"|\')(.*)\1$', r'\2', field)
debug.trace(T8, f"_preprocess_output({in_field!r}) == {field!r}")
return field
def _common_process(self, test):
"""Common process for each field in test"""
debug.trace(6, f'in _common_process({test!r})')
result = []
# Get indent used
self._indent_used = ''
if my_re.match(INDENT_PATTERN, test.actual):
self._indent_used = my_re.group(0)
debug.trace(T7, f'batspp (test {self._test_id}) - indent founded: "{self._indent_used}"')
# Preprocess command and output fields
## TODO4: uses T6 instead of 5 in traces (likewise elsewhere)
entire = test.entire
title = test.title
setup = self._preprocess_command(test.setup)
debug.trace_expr(5, test.actual)
actual = self._preprocess_command(test.actual)
debug.trace_expr(5, actual)
expected = self._preprocess_output(test.expected)
result = TestFieldTypes(*[entire, title, setup, actual, expected])
debug.trace(T7, f'batspp (test {self._test_id}) - _common_process({test!r}) => {result!r}')
return result
def _last_process(self, test):
"""Process test fields before convert into bats"""
# NOTE: if this is overrided, the result must be:
# result = ['title', 'setup', 'actual', 'expected']
result = test
debug.trace(T7, f'batspp (test {self._test_id}) - CustomTestsToBats._last_process({test}) => {result}')
return result
def _convert_to_bats(self, test):
"""Convert tests to bats format, returning test text and title"""
entire, title, setup, actual, expected = test
debug.trace_expr(T6, entire, title, setup, actual, expected, prefix="_convert_to_bats: ", delim="\n")
debug.assertion(not my_re.search(r"^\$", expected, flags=re.MULTILINE),
f"The expected output shouldn't have $ prompt at start of line: {expected!r}")
(actual, expected) = self.merge_continuation(actual, expected)
# Ignore if just comments
if IGNORE_ALL_COMMENTS:
entire_sans_comments = my_re.sub(r"^\s*\#.*\n", "", entire, flags=re.MULTILINE)
if not entire_sans_comments.strip():
debug.trace(T6, f"FYI: Ignoring test that is just comments: {gh.elide(entire)!r}")
return "", ""
# Process title
# Note: The test label starts with a number and includes optional user name (e.g, test-1-hello-world).
title_prefix = f'test-{self._test_id}'
if title:
title = (title_prefix + "-" + title.replace(" ", "-"))
else:
title = title_prefix
unspaced_title = my_re.sub(r'\s+', '-', title)
# note: remove special punctuation (TODO3: retain for comments)
unspaced_title = my_re.sub(r"([^ a-z0-9_-])", "_", unspaced_title)
# HACK: Extract setup command from entire match if '# Setup' indicator given
# but the setup section is empty.
if (EXTRACT_SETUP and (not setup) and my_re.search(r"#\s*Setup\n\$([^\n]+\n)", entire,
flags=re.IGNORECASE|re.MULTILINE)):
setup = my_re.group(1)
debug.trace(T6, f"Using setup fallback code extracted from entire match: {setup}")
# Similarly, get commands from entire section not present in setup nor actual
if AUGMENT_COMMANDS:
for command in re.findall(r"^\s*\$([^\n]+)\n", entire, flags=re.IGNORECASE|re.MULTILINE):
if command.strip() not in (setup + actual + expected):
debug.trace(T6, f"Adding missing command to setup: {command}")
if setup and (not re.search(r"[;\}]\s*$", setup)):
setup += ";"
setup += ("\t" + command + "\n")
debug.trace(T6, f"hacked_setup={setup!r}")
# Process setup commands
# Note: set COPY_DIR to copy files in current dir to temp. dir.
## TODO: temp_dir = Main.temp_base; put copy in setup if GLOBAL_TEST_DIR
debug.assertion(BASH_EVAL or not GLOBAL_TEST_DIR)
## OLD: gh.full_mkdir(test_folder)
self.num_tests += 1
setup_text = ""
if GLOBAL_TEST_DIR:
test_folder = gh.form_path(TEMP_DIR, "global-test-dir")
else:
test_subdir = unspaced_title
## TODO1: test_folder = gh.form_path(TEMP_DIR, test_subdir)
## NOTE: The above is leading to odd errors after running a dozen or so tests files
## '/bin/sh: 1: cannot open /dev/null: No such file'
test_folder = gh.form_path(".", test_subdir)
setup_text += (
f'\ttest_folder="{test_folder}"\n' +
f'\tmkdir --parents "$test_folder"\n' +
(f'\tcommand cp -Rp ./. "$test_folder"\n' if COPY_DIR else "") +
# note: warning added for sake of shellcheck
f'\tbuiltin cd "$test_folder" || echo Warning: Unable to "cd $test_folder"\n')
setup_sans_prompt = my_re.sub(r'^\s*\$', '\t', setup, flags=my_re.MULTILINE)
setup_text += setup_sans_prompt + "\n"
debug.trace_expr(T6, setup_text)
# actual and expected
actual_label = 'actual'
expected_label = 'expected' if self._assert_equals else 'not_expected'
setup_label = 'setup'
# Process assertion
assertion_text = "==" if self._assert_equals else "!="
# Process functions
actual_function = f'{unspaced_title}-{actual_label}'
actual_var = f'{actual_function}-result'.replace("-", "_")
expected_function = f'{unspaced_title}-{expected_label}'
expected_var = f'{expected_function}-result'.replace("-", "_")
setup_function = None
functions_text = ''
functions_text += self._get_bash_function(actual_function, actual)
# Note: to minimize issues with bash syntax, a bash here-document is used (e.g., <<END\n...\nEND\n).
# TOOO?: Use <<- variant so that leading tabs are ignored.
# TODO: use an external file (as the @here might fail if the example uses << as well)
# Note: The here-document delimiter is quoted to block variable interpolation
# https://stackoverflow.com/questions/4937792/using-variables-inside-a-bash-heredoc
expected_output = ('\tcat <<"END_EXPECTED"\n' +
((expected + "\n") if expected else "") +
'END_EXPECTED')
functions_text += self._get_bash_function(expected_function, expected_output,
output=True)
# Add special hooks for when '# Setup' or '# Continuation' specified
# HACK: Updates instance state to process such indicator comments (to avoid regex complication)
# note: The regex parsing isolates "# Setup" code only when '# Actual' used, which is
# not commonly used. (see CommandTests.__init__ for regex definition.)
has_setup_comment = re.search(r"#\s*Setup", entire, re.IGNORECASE)
has_wrapup_comment = re.search(r"#\s*Wrapup", entire, re.IGNORECASE)
has_continuation_comment = re.search(r"#\s*Continuation", entire, re.IGNORECASE)
use_setup_function = (has_setup_comment or has_continuation_comment or has_wrapup_comment or setup_text.strip())
if use_setup_function:
setup_function = f'{unspaced_title}-{setup_label}'
self._setup_funct = setup_function
functions_text += self._get_bash_function(setup_function, setup_text)
## OLD:
## has_continuation_comment = re.search(r"#\s*Continuation", entire, re.IGNORECASE)
## use_setup_function = (has_setup_comment or has_continuation_comment)
setup_call = ""
if use_setup_function:
debug.trace(T6, f"Using separate setup function {self._setup_funct}")
if not self._setup_funct:
system.print_error(f"Error: No setup function defined for test {unspaced_title}")
else:
setup_call = ("\t" + self._setup_funct + ";\n")
else:
## TODO: self._setup_funct = None
pass
# Get actual and expected results
# TODO3: use helper bash function to minimize boilerplate code
# TODO1: add output normalization (similar to _preprocess_output)
# Note: When evaluating the function, an evaluation context is not used so that
# the current process state gets modified, not the implicit child process.
# For example, 'actual=$(test-n-actual)' => 'test-n-actual > out; actual=$(cat out)'
# See https://stackoverflow.com/questions/23564995/how-to-modify-a-global-variable-within-a-function-in-bash
main_body = f"\tlocal {actual_var} {expected_var}\n"
if OLD_ACTUAL_EVAL:
main_body += f'\t{actual_var}="$({actual_function})"\n'
else:
out_file = gh.form_path(test_folder, f"{unspaced_title}.out")
main_body += (
f'\tout_file="{out_file}"\n' +
f'\t{actual_function} >| "$out_file"\n' +
f'\t{actual_var}="$(cat "$out_file")"\n')
main_body += f'\t{expected_var}="$({expected_function})"\n'
## TODO3: just normalize actual at runtime as expected can be done ahead of time
if NORMALIZE_WHITESPACE:
main_body += (f'\t{actual_var}="$(normalize-whitespace \"${actual_var}\")"\n' +
f'\t{expected_var}="$(normalize-whitespace \"${expected_var}\")"\n')
if STRIP_COMMENTS:
main_body += (f'\t{actual_var}="$(strip-comments \"${actual_var}\")"\n' +
f'\t{expected_var}="$(strip-comments \"${expected_var}\")"\n')
if IGNORE_SETUP_OUTPUT:
if (has_setup_comment or has_continuation_comment or has_wrapup_comment):
# note: setup and wrapup output ignored; however, code run above for side effects
# TODO2: make sure success not counted in stats
main_body += (f'\t{actual_var}=ignored\n' +
f'\t{expected_var}=ignored\n' +
'\tlet num_ignored_tests++\n' +
'\ttest_output_ignored=1\n')
else:
main_body += f'\ttest_output_ignored=0\n'
# Process debug
debug_text = ""
if not OMIT_TRACE:
verbose_print = '| hexview.perl' if self._verbose else ''
#
def esc(text, max_len=None):
"""Escape text for printing with single quotes replaced with double
Note: Uses string representation (repr), up to MAX_LEN characters;
intended for bash echo inside single quoted string, which precludes use of \'
"""
## TODO: single quoted text with unicode prime symbol
## TEST: return text.replace("'", '\\"')
## TODO: result = repr(text).replace("'", "\u2032") # U+2032: prime (′)
if max_len is None:
max_len = MAX_ESCAPED_LEN
result = gh.elide(repr(text).replace("'", '"'), max_len=max_len)
debug.trace(T9, f"esc({text!r} => {result}")
return result
#
# note: 'actual' here is the code, but 'expected' is the output
debug_text = ('\techo ""\n' +
('\t# shellcheck disable=SC2016,SC2028\n' if FILTER_SHELLCHECK else '') +
f"\techo '========== actual: {esc(actual)} =========='\n" +
f'\techo "${actual_var}"\n' +
(f'\techo "${actual_var}" {verbose_print}\n' if self._verbose else "") +
('\t# shellcheck disable=SC2016,SC2028\n' if FILTER_SHELLCHECK else '') +
f"\techo '========== expect: {esc(expected)} =========='\n" +
f'\techo "${expected_var}"\n' +
(f'\techo "${expected_var}" {verbose_print}\n' if self._verbose else "") +
'\techo "============================"\n')
# Construct bats tests
misc_code = ""
if not OMIT_MISC:
misc_code = (
f'\t# actual {{ {actual!r} }} => "${actual_var}"\n' +
f'\t# expect {{ {expected!r} }} => "${expected_var}"\n')
test_header = (f'@test "{unspaced_title}"' if not BASH_EVAL else f'function {unspaced_title}')
result = (f'{test_header} {{\n' +
(f'{setup_text}' if not use_setup_function else setup_call) +
f'{main_body}' +
f'{debug_text}' +
misc_code +
f'\t[ "${actual_var}" {assertion_text} "${expected_var}" ]\n' +
f'}}\n\n' +
f'{functions_text}\n')
debug.trace(T7, f'batspp (test {self._test_id}) - _convert_to_bats({test}) =>\n{result}')
return result, unspaced_title
def _get_bash_function(self, name, content, output=False):
"""Return bash function with NAME and code CONTENT, optionally for expected OUTPUT"""
if not output:
# Strip comments
content = my_re.sub(r"^\s*\#[^\n]*\n", "", content, flags=re.MULTILINE)
# Remove prompts
content = my_re.sub(r"^\s*\$ ", "", content, flags=re.MULTILINE)
# Make sure indented
if not content.startswith("\t"):
content = my_re.sub(r"^([^\t])", r"\t\1", content, flags=re.MULTILINE)
result = (f'function {name} () {{\n' +
'\t# no-op in case content just a comment\n' +
'\ttrue\n' +
'\n' +
f'{content}\n' +
'}\n\n')
debug.trace(T7, f'batspp (test {self._test_id}) - get_bash_function(name={name}, content={content}) => {result}')
return result
def trace_pattern_match(self, text):
"""Traces out the matching for TEXT in stages, using self._patterns one at a time.
This uses a greedy approximation to the regular regex matching with concatenation.
Note: Intended just as quick-n-dirty way to debug regex evaluation
"""
debug.trace(T6, f"trace_pattern_match({gh.elide(text, 512)!r})")
debug.trace_expr(T7, self._re_flags)
for p, pattern in enumerate(self._patterns):
debug.trace(T7, f"\tp{p + 1}: {pattern}")
# note: temporarily raises level of overly verbose regex matching
save_my_re_TRACE_LEVEL = my_re.TRACE_LEVEL
my_re.TRACE_LEVEL = T9
save_INDENT0 = debug.INDENT0
# Initialize
start = 0
p = 0
num_patterns = len(self._patterns)
matches = []
# Do searches incrementally