blob: 30c83b9c33d41e0b3bb28b02234039d471674451 [file]
#!/usr/bin/python
# get opencv dependency if needed. We do it here for imageDiff
# because we spawn multiple processes so we would have a race condition with each one trying to check and download opencv
import subprocess
import os.path
import pathlib
import sys
from collections import defaultdict
if not "NO_VENV" in os.environ.keys():
from venv import create
# create venv here and then install package
VENV_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), ".pyenv"))
if sys.platform.startswith('win32'):
PYTHON = os.path.join(VENV_DIR, "Scripts", "python.exe")
else:
PYTHON = os.path.join(VENV_DIR, "bin", "python")
if not os.path.exists(VENV_DIR):
create(VENV_DIR, with_pip=True)
subprocess.check_call([PYTHON, "-m", "pip", "install", "opencv-python-headless"])
else:
PYTHON = os.path.realpath(sys.executable)
TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template")
from genericpath import exists
import argparse
import glob
import csv
import threading
import queue
from xml.etree import ElementTree as ET
from typing import TypeVar
import shutil
import json
parser = argparse.ArgumentParser(description="Compare two directories of images")
parser.add_argument("--goldens", "-g", required=True, help="INPUT directory of correct images")
parser.add_argument("--candidates", "-c", required=True, help="INPUT directory of candidate images")
parser.add_argument("--output", "-o", required=True, help="OUTPUT directory to store differences")
parser.add_argument("--verbose", "-v", action='store_true', help="enable verbose output")
parser.add_argument("--build", "-b", default='release', choices=['debug', 'release'], help="build configuration")
parser.add_argument("-j", "--jobs", default=1, type=int, help="number of jobs to run in parallel")
parser.add_argument("-r", "--recursive", action='store_true', help="recursively diffs images in \"--candidates\" sub folders against \"--goldens\"")
parser.add_argument("-p", "--pack", action='store_true', help="copy candidates and goldens into output folder along with results")
parser.add_argument("-H", "--histogram_compare", action='store_true', help="Use histogram compare method to determine if candidate matches gold")
parser.add_argument("-t", "--threshold", default=0.06, type=float, help="if histogram_compare is set, then threshold used for histogram pass result otherwise the threshold for pixel diff pass result")
clean_mode = parser.add_mutually_exclusive_group(required=False)
clean_mode.add_argument("-x", "--clean", action='store_true', help="delete golden and candidate images that are identical, also dont add identical images to index.html")
clean_mode.add_argument("-f", "--fails_only", action='store_true', help="delete images of all tests except for fails, also only adds failing tests to index.html, acts the same as -x if histogram_compare is false")
args = parser.parse_args()
status_filename_base = "_imagediff_status"
status_filename_pattern = f"{status_filename_base}_%i_*.txt" % os.getpid()
show_commands = False
class TestEntry(object):
pass_entry_template:str = None
error_entry_template:str = None
identical_entry_template:str = None
missing_file_entry_template:str = None
@classmethod
def load_templates(cls, path):
with open(os.path.join(path, "error_entry.html")) as t:
cls.error_entry_template = t.read()
with open(os.path.join(path, "pass_entry.html")) as t:
cls.pass_entry_template = t.read()
with open(os.path.join(path, "identical_entry.html")) as t:
cls.identical_entry_template = t.read()
with open(os.path.join(path, "missing_file_entry.html")) as t:
cls.missing_file_entry_template = t.read()
def __init__(self, words, candidates_path, golden_path, output_path, device_name=None, browserstack_details=None):
self.diff0_path_abs = None
self.diff1_path_abs = None
self.device = device_name
self.browserstack_details = browserstack_details
self.name = words[0]
self.candidates_path_abs = os.path.join(candidates_path, f"{self.name}.png")
if args.pack and device_name is not None:
self.candidates_path = os.path.join(device_name, f"{self.name}.png")
self.golden_path = os.path.join("golden", f"{self.name}.png")
elif args.recursive:
self.candidates_path = pathlib.Path(os.path.relpath(os.path.join(candidates_path, f"{self.name}.png"), pathlib.Path(output_path).parent.absolute())).as_posix()
self.golden_path = pathlib.Path(os.path.relpath(os.path.join(golden_path, f"{self.name}.png"), pathlib.Path(output_path).parent.absolute())).as_posix()
else:
self.candidates_path = pathlib.Path(os.path.relpath(os.path.join(candidates_path, f"{self.name}.png"), output_path)).as_posix()
self.golden_path = pathlib.Path(os.path.relpath(os.path.join(golden_path, f"{self.name}.png"), output_path)).as_posix()
if len(words) == 2:
self.avg = None
self.histogram = None
self.type = words[1]
else:
self.max_diff = int(words[1])
self.avg = float(words[2])
self.total_diff_count = int(words[3])
self.total_pixels = int(words[4])
self.diff0_path_abs = os.path.join(output_path, f"{self.name}.diff0.png")
self.diff1_path_abs = os.path.join(output_path, f"{self.name}.diff1.png")
if device_name is not None:
self.diff0_path = os.path.join(device_name, f"{self.name}.diff0.png")
self.diff1_path = os.path.join(device_name, f"{self.name}.diff1.png")
else:
self.diff0_path = os.path.relpath(os.path.join(output_path, f"{self.name}.diff0.png"), output_path)
self.diff1_path = os.path.relpath(os.path.join(output_path, f"{self.name}.diff1.png"), output_path)
if len(words) == 6:
self.histogram = float(words[5])
if self.histogram < (1.0-args.threshold):
self.type = "failed"
else:
self.type = "pass"
else:
self.histogram = None
if self.max_diff > args.threshold:
self.type = "failed"
else:
self.type = "pass"
# this is equivalent of implementing == we are comparing by name for when we check against the correct golds to delete
def __eq__(self, other):
return self.name == other.name
# hash by name so that when we create a set out of entry list we condense it down to the number of goldens since we only care about them
def __hash__(self):
return hash(self.name)
# this is equivalent of implementing < operator. We use this for sorted and sort functions
def __lt__(self, other):
# Always sort by avg first. Histogram is a good heuristic to divide into
# "pass/fail" buckets, but it's helpful to then see the fail bucked
# sorted by avg, which is more sensitive to differences.
if (self.avg == other.avg and
self.histogram is not None and
other.histogram is not None):
# LOWER histogram values mean worse matches. Sort the bad matches first.
return self.histogram > other.histogram
else:
# HIGHER avg values mean worse matches. Sort the bad matches first.
return self.avg < other.avg
def __str__(self):
vals = dict()
vals['name'] = f"{self.name} ({self.device})" if self.device is not None else self.name
vals['url'] = self.browserstack_details['browser_url'] if self.browserstack_details is not None else ' '
if self.type == 'missing_golden':
# show candidate, since golden is missing
vals['image'] = self.candidates_path
return self.missing_file_entry_template.format_map(vals)
elif self.type == 'missing_candidate':
# show golden, since candidate is missing
vals['image'] = self.golden_path
return self.missing_file_entry_template.format_map(vals)
vals['golden'] = self.golden_path
vals['candidate'] = self.candidates_path
if self.type == "pass" or self.type == "failed":
vals['max'] = self.max_diff
vals['avg'] = self.avg
vals['total_diff'] = self.total_diff_count
vals['percent'] = float(self.total_diff_count) / float(self.total_pixels)
vals['histogram'] = self.histogram if self.histogram is not None else 'None'
vals['diff0'] = self.diff0_path
vals['diff1'] = self.diff1_path
if self.type == 'pass':
return self.pass_entry_template.format_map(vals)
else:
return self.error_entry_template.format_map(vals)
if self.type == 'identical':
return self.identical_entry_template.format_map(vals)
return ''
def clean(self):
if args.verbose:
print(f"cleaning TestEntry {self.name} - {self.device}")
# removes image files to save space, this is done for clean and fails only arguments
os.remove(self.candidates_path_abs)
# if we are packing the files, also delete the packed ones
if args.pack:
os.remove(os.path.join(args.output, self.candidates_path))
# if we have diff files, delete those too
if self.diff0_path_abs is not None:
os.remove(self.diff0_path_abs)
# this should always be true if diff0 is not none but just to be safe we do a separate check
if self.diff1_path_abs is not None:
os.remove(self.diff1_path_abs)
@property
def success(self):
return self.type == "pass" or self.type == "identical"
@property
def csv_dict(self):
val = dict()
val['file_name'] = self.name
val['original'] = self.golden_path
val['candidate'] = self.candidates_path
if self.type == "pass" or self.type == "failed":
val['max_rgb'] = str(self.max_diff)
val['avg_rgb'] = str(self.avg)
val['pixel_diff_count'] = str(self.total_diff_count)
val['pixel_diff_percent'] = '100'
if self.histogram is not None:
val['hist_result'] = str(self.histogram)
val['color_diff'] = self.diff0_path
val['pixel_diff'] = self.diff1_path
elif self.type == 'identical':
val['max_rgb'] = '0'
val['avg_rgb'] = '0'
val['pixel_diff_count'] = '0'
val['pixel_diff_percent'] = '100'
if args.histogram_compare:
val['hist_result'] = '1.0'
val['color_diff'] = ''
val['pixel_diff'] = ''
return val
def shallow_copy_images(src, dest):
file_names = [file for file in os.scandir(src) if file.is_file() and '.png' in file.name]
for file in file_names:
shutil.copyfile(file.path, os.path.join(dest, file.name))
def write_csv(entries, origpath, candidatepath, diffpath, missing_candidates):
origpath = os.path.relpath(origpath, diffpath)
candidatepath = os.path.relpath(candidatepath, diffpath)
height = 256
with open(os.path.join(diffpath, "data.csv"), "w") as csvfile:
fieldnames = ['file_name','original', 'candidate', 'max_rgb', 'avg_rgb', 'pixel_diff_count', 'pixel_diff_percent', 'color_diff', 'pixel_diff']
if args.histogram_compare:
fieldnames.extend(['hist_result'])
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for name in missing_candidates:
if args.histogram_compare:
writer.writerow({
'file_name': name.split('.')[0],
'original': os.path.join(origpath, name),
'candidate': '',
'max_rgb':255,
'avg_rgb':255,
# i guess its all of em, not 1 of em, but whatever
'pixel_diff_count': 1,
'pixel_diff_percent': '100',
'hist_result' : 1.0,
'color_diff': '',
'pixel_diff': ''
})
else:
writer.writerow({
'file_name': name.split('.')[0],
'original': os.path.join(origpath, name),
'candidate': '',
'max_rgb':255,
'avg_rgb':255,
# i guess its all of em, not 1 of em, but whatever
'pixel_diff_count': 1,
'pixel_diff_percent': '100',
'color_diff': '',
'pixel_diff': ''
})
for entry in entries:
writer.writerow(entry.csv_dict)
def write_min_csv(total_passing, total_failing, total_missing_candidates, total_missing_goldens, total_identical, total_entries, csv_path):
# delete and old data
if os.path.exists(csv_path):
os.remove(csv_path)
with open(csv_path, 'w', newline='') as csv_file:
csv_writer = csv.DictWriter(csv_file, fieldnames=['type', 'number'])
csv_writer.writerow({'type':'failed', 'number' : str(total_failing)})
csv_writer.writerow({'type':'pass', 'number' : str(total_passing)})
csv_writer.writerow({'type':'missing_candidates', 'number' : str(total_missing_candidates)})
csv_writer.writerow({'type':'missing_goldens', 'number' : str(total_missing_goldens)})
csv_writer.writerow({'type':'identical', 'number' : str(total_identical)})
csv_writer.writerow({'type':'total', 'number' : str(total_entries)})
def diff_worker(index, work_queue, golden, candidate, output, parent_pid):
# One long-lived image_diff.py process that pulls filenames off the shared
# work_queue one at a time. Keeping the process alive means cv2 is imported
# only once (that import + process spawn dominate on Windows).
cmd = [PYTHON, "image_diff.py", "--serve",
"-g", golden,
"-c", candidate,
# Each process writes its own status file in order to avoid race conditions.
"-s", "%s_%i_%i.txt" % (status_filename_base, parent_pid, index)]
if output is not None:
cmd.extend(["-o", output])
if args.verbose:
cmd.extend(["-v", "-l"])
if args.histogram_compare:
cmd.extend(["-H"])
if show_commands:
print(" ".join(cmd))
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True)
try:
while True:
try:
filename = work_queue.get_nowait()
except queue.Empty:
break
proc.stdin.write(filename + "\n")
proc.stdin.flush()
# Block until the worker signals it finished this image. If the pipe
# closed early the worker died; stop feeding it (the missing status
# line is caught by the line-count check in diff_directory_shallow).
if proc.stdout.readline().strip() != "done":
print("image_diff.py worker %i died processing %s" % (index, filename))
break
finally:
proc.stdin.close()
proc.wait()
def parse_status(candidates_path, golden_path, output_path, device_name, browserstack_details):
total_lines = 0
test_entries = []
success = True
status_files = glob.glob(status_filename_pattern)
if not status_files:
print('Not a single status file got written, are you just starting new?')
for status_filename in status_files:
for line in open(status_filename, "r").readlines():
total_lines += 1
words = line.rstrip().split('\t')
entry = TestEntry(words, candidates_path, golden_path, output_path, device_name, browserstack_details)
test_entries.append(entry)
if not entry.success:
success = False
return (total_lines, test_entries, success)
def diff_directory_shallow(candidates_path, output_path, golden_path, device_name=None, browserstack_details=None):
original_filenames = set((file.name for file in os.scandir(candidates_path)
if file.is_file() and file.name.endswith('.png')))
candidate_filenames = set(os.listdir(golden_path))
intersect_filenames = original_filenames.intersection(candidate_filenames)
missing = []
for file in original_filenames.difference(candidate_filenames):
print(f'Candidate file {file} missing in goldens.')
missing.append(TestEntry([file.split('.')[0], 'missing_golden'], candidates_path, golden_path, output_path, device_name, browserstack_details))
for file in candidate_filenames.difference(original_filenames):
print(f'Golden file {file} missing in candidates.')
missing.append(TestEntry([file.split('.')[0], 'missing_candidate'], candidates_path, golden_path, output_path, device_name, browserstack_details))
if args.jobs > 1:
print("Diffing %i candidates in %i processes..." % (len(intersect_filenames), args.jobs))
else:
print("Diffing %i candidates..." % len(intersect_filenames))
sys.stdout.flush()
# Fill a shared queue with every filename.
work_queue = queue.Queue()
for filename in intersect_filenames:
work_queue.put(filename)
# Generate the diffs (if any) and write to the status file(s). Start up to
# `jobs` long-lived image_diff.py workers that each pull filenames off the
# queue as fast as they can finish them.
njobs = min(args.jobs, len(intersect_filenames))
parent_pid = os.getpid()
threads = []
for index in range(njobs):
t = threading.Thread(target=diff_worker,
args=(index, work_queue, golden_path,
candidates_path, output_path, parent_pid))
t.start()
threads.append(t)
for t in threads:
t.join()
(total_lines, entries, success) = parse_status(candidates_path, golden_path, output_path, device_name, browserstack_details)
entries.extend(missing)
print(f'finished with Succes:{success} and {total_lines} lines')
if total_lines != len(intersect_filenames):
print(f"Internal failure: Got {total_lines} status lines. Expected {len(intersect_filenames)}.")
success = False
if original_filenames.symmetric_difference(candidate_filenames):
print("golden and candidate directories do not have identical files.")
success = False
# cleanup our scratch files
for status_filename in glob.iglob(status_filename_pattern):
os.remove(status_filename)
return (entries, missing, success)
# Sort descending total failure count (missing + failure), then by descending failure count, then by name
# (put the failed ones at the top)
def device_entry_sort_key(kv):
return (-(kv[1]["missing_candidate"] + kv[1]["failed"]), -(kv[1]["failed"]), kv[0])
# returns entries sorted into identical, passing and failing as well as html str list of each
# based on arguments passed, we may or may not return all of the string lists, but we always return the object lists
def sort_entries(entries):
# we dont need an intermediate object list because we never sort ot clean these, so direct to html
missing_golden_str = [str(entry) for entry in entries if entry.type == "missing_golden"]
missing_candidate_str = [str(entry) for entry in entries if entry.type == "missing_candidate"]
failed_entires = [entry for entry in entries if entry.type == "failed"]
pass_entires = [entry for entry in entries if entry.type == "pass"]
identical_entires = [entry for entry in entries if entry.type == "identical"]
sorted_failed_entires = sorted(failed_entires, reverse=True)
sorted_failed_str = [str(entry) for entry in sorted_failed_entires]
# Build a list of stat counts by device name
all_device_stats = dict()
for entry in entries:
if entry.device not in all_device_stats:
all_device_stats[entry.device] = defaultdict(int)
all_device_stats[entry.device]["url"] = entry.browserstack_details['browser_url'] if entry.browserstack_details is not None else ' '
all_device_stats[entry.device][entry.type] += 1
# Now build the device summary text using the template
device_summary_str = ""
with open(os.path.join(TEMPLATE_PATH, "device_summary_entry.html")) as t:
device_summary_entry_template = t.read()
for device_name, device_summary in sorted(all_device_stats.items(), key=device_entry_sort_key):
none_succeeded = device_summary["pass"] == 0 and device_summary["identical"] == 0
any_failed = device_summary["failed"] > 0
any_missing = device_summary["missing_candidate"] > 0
device_summary_str += device_summary_entry_template.format(
name=device_name,
url=device_summary["url"],
failed_count=device_summary["failed"] if any_failed else "-",
failed_class="fail" if any_failed else "deemphasize",
missing_count=device_summary["missing_candidate"] if any_missing else "-",
missing_class="fail" if any_missing else "deemphasize",
pass_count=device_summary["pass"],
pass_class="fail" if none_succeeded else "success",
identical_count=device_summary["identical"],
identical_class="fail" if none_succeeded else "" if device_summary["identical"] == 0 else "success")
# if we are only doing fails then only sort those and return empty html lists for "pass" and "identical" we still build and return
# identical and pass object lists for cleaning, but we dont bother sorting them
if args.fails_only:
return (
sorted_failed_entires,
pass_entires,
identical_entires,
sorted_failed_str,
[],
[],
missing_golden_str,
missing_candidate_str,
device_summary_str,
len(all_device_stats))
# now sort passed entires and build the html list
sorted_passed_entires = sorted(pass_entires, reverse=True)
sorted_passed_str = [str(entry) for entry in sorted_passed_entires]
# if we are cleaning then return empty html list for identical. do everything else the same
if args.clean:
return (sorted_failed_entires, sorted_passed_entires, identical_entires, sorted_failed_str, sorted_passed_str, [], missing_golden_str, missing_candidate_str, device_summary_str, len(all_device_stats))
# otherwise build identical html entry list and include it in the return
identical_str = [str(entry) for entry in identical_entires]
return (
sorted_failed_entires,
sorted_passed_entires,
identical_entires,
sorted_failed_str,
sorted_passed_str,
identical_str,
missing_golden_str,
missing_candidate_str,
device_summary_str,
len(all_device_stats))
def write_html(templates_path, failed_entries, passing_entries, identical_entries, missing_golden_entries, missing_candidate_entries, device_summary_str, device_number, output_path):
with open(os.path.join(templates_path, "index.html")) as t:
index_template = t.read()
html = index_template.format(identical=" ".join(identical_entries), passing=" ".join(passing_entries),
failed=" ".join(failed_entries), failed_number=len(failed_entries),
passing_number=len(passing_entries), identical_number=len(identical_entries),
missing_candidate=" ".join(missing_candidate_entries), missing_candidate_number=len(missing_candidate_entries),
missing_golden=" ".join(missing_golden_entries), missing_golden_number=len(missing_golden_entries),
device_summaries=device_summary_str, device_number=device_number,
pass_hide_class="hidden" if args.fails_only else "",
identical_hide_class="hidden" if args.fails_only or args.clean else "")
with open(os.path.join(output_path, "index.html"), "w") as file:
file.write(html)
#copy our icon to the output folder
shutil.copyfile(os.path.join(TEMPLATE_PATH, "favicon.ico"), os.path.join(output_path, "favicon.ico"))
def diff_directory_deep(candidates_path, output_path):
golden_path = args.goldens
if args.pack:
new_golden_path = os.path.join(output_path, "golden")
os.makedirs(new_golden_path, exist_ok=True)
shallow_copy_images(args.goldens, new_golden_path)
golden_path = new_golden_path
all_entries = []
for folder in os.scandir(candidates_path):
if folder.is_dir():
if folder.name[0] == '.':
continue
output = os.path.join(output_path ,folder.name)
os.makedirs(output, exist_ok=True)
browserstack_details = None
browserstack_details_path = os.path.join(folder.path, "session_details")
if os.path.exists(browserstack_details_path):
with open(browserstack_details_path, 'rt') as file:
browserstack_details = json.load(file)
os.remove(browserstack_details_path)
(entries, _, _) = diff_directory_shallow(folder.path, output, golden_path, folder.name, browserstack_details)
all_entries.extend(entries)
if args.pack:
shallow_copy_images(folder.path, output)
(failed, passed, identical, failed_str, passed_str, identical_str, missing_golden_str, missing_candidate_str, device_summary_str, device_number) = sort_entries(all_entries)
to_clean = []
to_check = []
# choose who to clean and who to check against
if args.clean:
to_clean = identical
to_check = failed + passed
if args.fails_only:
to_clean = identical + passed
to_check = failed
# clean them
for obj in to_clean:
obj.clean()
# only remove goldens that are in to_clean and not to_check so that we keep goldens that are still used
# this part is why we needed __eq__ and __hash__ in our TestEntry class
for obj in set(to_clean) - set(to_check):
golden_file = os.path.join(golden_path, f"{obj.name}.png")
if args.verbose:
print("deleting orphaned golden " + golden_file)
os.remove(golden_file)
if args.pack:
# remember to remove the original if we packed it
os.remove(os.path.join(args.goldens, f"{obj.name}.png"))
write_html(TEMPLATE_PATH, failed_str, passed_str, identical_str, missing_golden_str, missing_candidate_str, device_summary_str, device_number, output_path)
print(f"total entries {len(all_entries)}")
write_min_csv(len(passed), len(failed), len(missing_candidate_str), len(missing_golden_str), len(identical), len(all_entries), output_path + "/issues.csv")
def main(argv=None):
if not os.path.exists(args.goldens):
print("Can't find goldens " + args.goldens)
return -1
if not os.path.exists(args.candidates):
print("Can't find candidates " + args.candidates)
return -1
# delete output dir if exists
shutil.rmtree(args.output, ignore_errors=True)
# remake output dir, this will make it correctly
# even if it requires creating mulltiple directories
os.makedirs(args.output, exist_ok=True)
# reset our scratch files
for status_filename in glob.iglob(status_filename_pattern):
os.remove(status_filename)
TestEntry.load_templates(TEMPLATE_PATH)
if args.recursive:
diff_directory_deep(args.candidates, args.output)
else:
(entries, missing, success) = diff_directory_shallow(args.candidates, args.output, args.goldens)
if len(entries) > 0:
(failed, passed, identical, failed_str, passed_str, identical_str, missing_golden_str, missing_candidate_str, device_summary_str, device_number) = sort_entries(entries)
assert(len(failed) + len(passed) + len(identical) + len(missing_candidate_str) + len(missing_golden_str) == len(entries))
write_html(TEMPLATE_PATH, failed_str, passed_str, identical_str, missing_golden_str, missing_candidate_str, device_summary_str, device_number, args.output)
# note could add these to the html output but w/e
missing_candidates = [os.path.basename(entry.candidates_path_abs) for entry in missing if entry.type == 'missing_candidate']
write_csv(entries, args.goldens, args.candidates, args.output, missing_candidates)
print("Found", len(entries) - len(identical), "differences,",
len(failed), "failing.")
# here we have to do a lot less work than the diff_directory_deep since we know goldens are not shared with other TestEntries
if args.fails_only:
for obj in identical+passed:
obj.clean()
golden_path = os.path.join(args.goldens, f"{obj.name}.png")
if args.verbose:
print(f"deleting orphaned golden {golden_path}")
os.remove(golden_path)
elif args.clean:
for obj in identical:
obj.clean()
golden_path = os.path.join(args.goldens, f"{obj.name}.png")
if args.verbose:
print(f"deleting orphaned golden {golden_path}")
os.remove(golden_path)
# if we are in fail only mode than make it succesful when there are only "passing" entries
if args.fails_only:
if failed:
# if there were diffs, its gotta fail
print("FAILED.")
return -1
# otherwise fail like normal
elif not success:
# if there were diffs, its gotta fail
print("FAILED.")
return -1
return 0
if __name__ == "__main__":
sys.exit(main())