CODE WALKTHROUGH · 코드 해부

psytester
코드 해부

프로젝트 전체 코드의 구성과 연계 관계를 도식으로 보여주고, 핵심 코드를 펼쳐 문장·라인 단위로 풀어서 설명합니다. psytester는 사실상 tester.py 한 파일에 모든 로직이 담긴 단일 모듈 프로그램입니다.

대상 소스: source_reference/psytester/ · 총 746줄의 tester.py

구조부터 라인 해설까지
01 프로젝트 구성 · STRUCTURE

파일은 몇 개 안 되고,
로직은 한 파일에 모여 있다

패키지·메타데이터를 빼면 실질 코드는 tester.py 하나. 나머지는 설정 템플릿과 문서입니다.

psytester/                 # GitHub / PyPI 저장소 루트
├─ setup.py            패키지 메타 + 진입점 정의
├─ update_version.py   버전 일괄 변경(개발용)
├─ ReadMe.md           사용 설명
├─ changelog.txt       버전별 변경 이력
├─ LICENSE             MIT
└─ psytester/          # 실제 파이썬 패키지
   ├─ tester.py       본체 — 모든 로직 + 진입점 _main()
   ├─ topcoder.cfg     Topcoder 템플릿
   ├─ old_topcoder.cfg 구버전 Topcoder 템플릿
   └─ atcoder.cfg      AtCoder 템플릿
02 연계 정보 · LINKAGE

무엇이 무엇을 부르고,
무엇을 읽고 쓰는가

터미널 명령 psytester는 진입점 _main()으로 연결되고, 동작은 전부 tester.cfg 설정이 좌우합니다.

setup.py패키지 정의
console_scripts
psytester → psytester.tester:_main
tester.py단일 모듈 · 진입점 _main()
imports ▾
tabulatecoloramaargparseconfigparsersubprocessthreadingqueuejsonremath
설정 연계 ▾
3개 템플릿 .cfgtopcoder · old · atcoder
— config --load 복사 →
tester.cfg작업 폴더의 설정
← run / show 읽음 —
run · show 모드동작을 cfg가 지배
03 실행 흐름 · CONTROL FLOW

_main()이 4갈래로 분기한다

argparse가 모드를 정하면, 각 모드가 아래 함수들을 순서대로 호출합니다.

_main() → argparse → args.mode모드에 따라 아래로 분기
config (c)
shutil.copy 템플릿--load / --save--delete / --list
run (r)
tests_queue 채움Thread × Nworker_loop()run_test()→ name.res 기록
show (s)
find_res_files()load_res_file()show_summary()process_raw_scores()apply_filter()parse_color()
find (f)
load_res_file()apply_filter()정렬 + limit→ JSON 출력
05 tester.py 라인 해설 · ANNOTATED SOURCE

함수별로 펼쳐 본 본체

왼쪽은 실제 소스 그대로, 오른쪽은 그 구간에 대한 해설입니다. 제목을 누르면 접을 수 있어요.

def try_str_to_numeric(x)문자열 → 숫자 자동 캐스팅L71–81
def try_str_to_numeric(x):
채점기 출력에서 뽑아낸 문자열 값을 알맞은 숫자 타입으로 바꾸는 헬퍼. 점수·메타데이터가 정수인지 실수인지 문자열인지 자동 판별합니다.
    if x is None:
        return None

    try: 
값이 없으면(None) 그대로 None을 돌려주는 가드.
        return int(x)
먼저 정수(int)로 변환을 시도합니다.
    except ValueError:
        try:
            return float(x)
        except ValueError:
            return x
정수가 아니면 실수(float)로, 그것도 실패하면 원본 문자열을 그대로 둡니다 — “int → float → str” 폴백 캐스케이드.
def fatal_error(msgs, exit_main=False)치명적 오류 출력 후 종료L84–94
def fatal_error(msgs, exit_main=False):
복구 불가능한 오류를 출력하고 프로그램을 끝내는 단일 창구. 한 줄 문자열 또는 여러 줄 메시지를 받습니다.
    if isinstance(msgs, str):
        msgs = [msgs]
문자열 하나로 들어와도 리스트로 감싸 이후 처리를 일관되게 만듭니다.
    print('Fatal Error:', msgs[0])
    for msg in msgs[1:]:
        print(msg)
        
첫 줄은 “Fatal Error:” 접두어와 함께, 나머지 줄들은 그대로 출력합니다.
    if exit_main:
        os._exit(1)
    else:
        sys.exit(1)
워커 스레드에서 호출됐을 때는 os._exit로 프로세스 전체를 강제 종료(스레드에서 sys.exit는 메인을 못 멈추기 때문), 평상시엔 sys.exit.
def parse_color(color)cfg 색 이름 → ANSI 코드L97–104
def parse_color(color):
설정 파일의 색 이름(예: BRIGHT_YELLOW)을 colorama의 실제 ANSI 이스케이프 시퀀스로 변환합니다.
    color = color.upper()
    style = colorama.Style.NORMAL
대문자로 정규화하고 기본 스타일을 NORMAL로 둡니다.
    if '_' in color:
        style, color = color.split('_')
        style = getattr(colorama.Style, style)
'STYLE_COLOR' 형태면 밑줄로 분리해 스타일(BRIGHT/DIM)과 색을 따로 얻습니다.
    color = getattr(colorama.Fore, color) if color not in ['DEFAULT', '*'] else ''
    return style + color    
DEFAULT나 '*'는 빈 문자열(=터미널 기본색), 그 외엔 colorama.Fore의 해당 색 코드. 최종적으로 스타일+색 시퀀스를 반환합니다.
def run_test(test) -> Dict시드 1개 실행 → 점수 추출 (핵심)L107–186
def run_test(test) -> Dict:
시드 하나에 대해 채점기를 실행하고 출력에서 점수·메타데이터를 추출해 dict로 돌려주는 핵심 워커. 여러 스레드가 이 함수를 동시에 호출합니다.
    retries_left = args.retry

    while True: 
재시도 횟수를 초기화합니다. 아래 while True 루프가 (점수 추출 실패 시) 재시도를 담당합니다.
        seed = test['seed']
처리할 시드 번호를 꺼냅니다.
        run_dir = args.name or '_default'
        
        output_dir = cfg["general"]["tests_dir"] + (f'/{run_dir}' if cfg["general"]["merge_output_dirs"].lower() == "false" else '')
        if args.debug:
            print()
            print('Running test:', test)
            print('Working directory:', output_dir)
        
실행 이름이 있으면 그 하위 폴더에, 없으면 _default에 출력. merge_output_dirs 설정에 따라 출력 폴더 경로가 결정됩니다.
        os.makedirs(output_dir, exist_ok=True)
        
출력 폴더를 생성합니다(이미 있으면 통과).
        def parse_cmd(s):
            s = s.replace('%SEED%', str(seed))
            for i in range(1, 10):
                s = s.replace(f'%SEED0{i}%', f'{seed:0{i}}')
            s = s.replace('%OUTPUT_DIR%', output_dir)
            s = s.replace('%TESTER_ARGS%', args.tester_arguments)
            return s
        
명령 문자열 안의 템플릿 토큰(%SEED%, %SEED0n%, %OUTPUT_DIR%, %TESTER_ARGS%)을 실제 값으로 치환하는 내부 함수.
        cmd = parse_cmd(cfg['general']['cmd_tester'])
        output_files = [parse_cmd(s) for s in cfg['general']['output_files'].split(',')]
        if args.debug:
            print('general/cmd_tester')
            print('Original:', cfg["general"]["cmd_tester"])
            print('Parsed:', cmd)
            print('general/output_files')
            print('Original:', cfg['general']['output_files'])
            print('Parsed:', output_files)
            print()
            print('Executing cmd_tester')
        
        
설정의 채점기 실행 명령(cmd_tester)과 결과를 읽을 출력 파일 목록(output_files)을 토큰 치환합니다.
        start_time = time.time()
        completed_run = subprocess.run(cmd, shell=True)
        rv = {'id': seed}
        
        if args.debug:
            print(f'cmd_tester finished with {completed_run.returncode} return code after {round(time.time() - start_time, 6)} seconds')
            print()
            print('Contents of the output files:')
                
        
시간 측정을 시작하고 subprocess.run으로 셸에서 채점기를 실제 실행합니다. rv에는 시드 id가 먼저 담깁니다.
        for output_file in output_files:
            if args.debug:
                print(f'File: {output_file}')
                if not os.path.exists(output_file):
                    fatal_error(f'Output file {output_file} doesn\'t exist; this means either cmd_tester/output_files is incorrectly defined or your tester/solver crashed and didn\'t produce that file', True)
채점기가 남긴 각 출력 파일을 순회합니다(디버그 모드에선 파일 존재 여부도 확인).
            with open(output_file) as f:
                for line in f:
                    for pattern in patterns:
                        m = re.match(pattern, line)
파일을 한 줄씩 읽으며, 각 줄을 등록된 모든 정규식 패턴과 대조합니다.
                        if m: 
                            if args.debug:
                                print(f'!!! Successful match with {pattern} pattern')
                            m = m.groupdict()
                            if 'VARIABLE' in m and 'VALUE' in m:
                                m[m['VARIABLE']] = m['VALUE']
                                del m['VARIABLE']
                                del m['VALUE']
                            rv.update({k: try_str_to_numeric(v) for k, v in m.items()})
                            if args.debug:
                                print('!!! Extracted dict:', m, 'New dict:', rv)
                        
매칭되면 named group을 dict로 뽑습니다. 특수 그룹 VARIABLE/VALUE가 있으면 “변수명=값” 한 쌍으로 변환. 추출값은 try_str_to_numeric로 숫자화해 rv에 병합합니다.
            if cfg['general']['keep_output_files'].lower() != 'true':
                os.remove(output_file)
                    
설정이 보존을 원치 않으면 출력 파일을 삭제해 디스크를 아낍니다.
        if 'score' not in rv:
            if retries_left:
                retries_left -= 1
                print(f'\r[Warning] Seed: {seed} cointains no score, retrying ({retries_left} retries left)')
                continue
            print(f'\r[Error] Seed: {seed} cointains no score')
            
        return rv
점수를 끝내 못 뽑았으면, 재시도가 남았을 땐 경고 후 while로 다시 시도하고, 아니면 에러를 찍습니다. 마지막에 결과 dict(rv)를 반환합니다.
def find_res_files(dir, include, exclude).res 결과 파일 수집·필터L189–208
def find_res_files(dir='.', include_patterns=None, exclude_patterns=None):
폴더에서 결과 파일(.res)들을 찾고 include/exclude 정규식으로 거르는 함수.
    files_list = [path for path in os.listdir(dir) if path.endswith(cfg["general"]["results_ext"])]
    
확장자가 results_ext(.res)인 파일만 1차로 모읍니다.
    try:
        if include_patterns:
            files_set = set()
            for pattern in include_patterns:
                m = re.compile(pattern)
                for file in files_list:
                    if m.search(file):
                        files_set.add(file)
            files_list = list(files_set)
        if exclude_patterns:
            for pattern in exclude_patterns:
                m = re.compile(pattern)
                files_list = [file for file in files_list if not m.search(file)]
    except re.error:
        fatal_error('Supplied patterns are not valid regexp')
        
include 패턴이 있으면 매칭되는 파일만 남기고, exclude 패턴이 있으면 그것들을 제거합니다. 정규식이 잘못되면 치명 오류.
    return [f'{dir}/{file}' for file in files_list]
디렉터리 경로를 붙여 전체 경로 리스트로 반환합니다.
def load_res_file(path) -> Dict.res 파일 → {시드: 결과}L211–218
def load_res_file(path) -> Dict[int, float]:
결과 파일을 읽어 “시드 id → 결과 dict” 매핑으로 만듭니다.
    if not os.path.exists(path):
        fatal_error(f'Cannot locate {path} results file')
파일이 없으면 치명 오류.
    with open(path) as f:
        lines = f.read().splitlines()
    results = [json.loads(line) for line in lines]
줄 단위로 읽어 각 줄을 json.loads — 한 줄이 한 시드의 결과입니다.
    return {result['id']: result for result in results} 
시드 id를 키로 하는 dict로 재구성해 반환.
def process_raw_scores(scores, scoring)점수 정규화 (raw/min/max)L221–231
def process_raw_scores(scores: List[float], scoring: str) -> List[float]: 
여러 솔루션의 원점수 리스트를 scoring 방식에 따라 상대 점수로 변환합니다. 스코어보드 순위의 토대.
    if scoring=='raw':
        return scores
raw: 변환 없이 절대 점수 그대로(단순 합산용).
    if scoring=='min':
        best_score = min([math.inf] + [score for score in scores if score > 0])
        return [best_score / score if score > 0 else 0 for score in scores]
min(낮을수록 좋음): 0 초과 점수 중 최솟값을 best로 잡고 best/score. 실패(0 이하)는 0.
    if scoring=='max':
        best_score = max([0] + [score for score in scores if score > 0])
        return [score / best_score if score > 0 else 0 for score in scores]
    
max(높을수록 좋음): 최댓값을 best로 잡고 score/best. 각 점수가 0~1로 상대화됩니다.
    fatal_error(f'Unknown scoring function: {scoring}')
정의되지 않은 scoring 값이면 오류.
def apply_filter(tests, data, filter)조건으로 시드 집합 거르기L234–243
def apply_filter(tests, data, filter):
'VAR=값' 또는 'VAR=A-B' 형식의 조건으로 테스트(시드) 집합을 거릅니다. 그룹핑·필터링의 공통 엔진.
    var, range = filter.split('=')
'=' 기준으로 변수명과 범위 문자열을 분리합니다.
    if '-' in range:
        lo, hi = range.split('-')
        lo = try_str_to_numeric(lo) if lo else min([data[test][var] for test in tests])
        hi = try_str_to_numeric(hi) if hi else max([data[test][var] for test in tests])
        return [test for test in tests if lo <= data[test][var] <= hi]
범위(A-B)면 lo·hi를 파싱하되 빈 쪽은 데이터의 최소/최대로 자동 채우고, lo ≤ 값 ≤ hi인 테스트만 남깁니다.
    else:
        value = try_str_to_numeric(range)
        return [test for test in tests if data[test][var] == value]
단일 값이면 그 값과 정확히 일치하는 테스트만 남깁니다.
def show_summary(runs, tests, data, groups, filters)스코어보드 표 생성·출력 (가장 큰 함수)L246–451
def show_summary(runs: Dict[str, Dict[int, float]], tests: Union[None, List[int]] = None, data=None, groups=None, filters=None):
여러 결과 파일을 비교해 스코어보드 표를 만들고 색을 입혀 출력하는 메인 함수. show 모드의 심장.
    if not tests:
        tests_used = [set(run_results.keys()) for run_name, run_results in runs.items()]
        tests = tests_used[0].intersection(*tests_used[1:])
    else:
        # TODO: error check if tests are cointained in intersection of all results files?
        pass
비교할 시드를 안 줬으면 모든 결과 파일에 공통으로 존재하는 시드의 교집합을 사용합니다.
    if not tests:
        fatal_error('There are no common tests within the results files (maybe one of the results files is empty?)')
공통 시드가 하나도 없으면(예: 빈 결과 파일) 오류.
    if not data and (filters or groups):
        fatal_error('Filters/Groups used but no data file is provided')
그룹/필터를 쓰는데 메타데이터 파일이 없으면 오류.
    if filters:
        initial_tests_no = len(tests)
        for filter in filters:
            tests = apply_filter(tests, data, filter)
        print(f'Filtered {initial_tests_no} tests to {len(tests)}')
            
필터가 있으면 조건마다 테스트 집합을 줄이고, 몇 개로 줄었는지 알려줍니다.
    # init color commands
    even_row_cmd = ''
    odd_row_cmd = ''
    header_cmd = ''
    group_max_cmd = ''
    reset_cmd = ''
    if cfg['general']['show_colors'].lower() == 'true':
        try:
            colorama.init()
            even_row_cmd = parse_color(cfg['general']['even_row_color'])
            odd_row_cmd  = parse_color(cfg['general']['odd_row_color'])
            header_cmd = parse_color(cfg['general']['header_color'])
            group_max_cmd = parse_color(cfg['general']['group_max_color'])
            reset_cmd = colorama.Style.RESET_ALL
        except AttributeError:
            traceback.print_exc()
            fatal_error('One of the specified colors is not valid. Refer to the cfg file for valid combinations')

색 명령을 초기화. show_colors가 true면 colorama를 켜고 cfg의 헤더·짝/홀수 행·최고점 색을 parse_color로 ANSI 코드화합니다.
    # create groups
    group_names = ['Overall']
    group_tests = [tests]
    
기본 그룹 'Overall'(전체)로 시작합니다.
    if groups:
        for group in groups:
            var = None
            if '=' in group: var = group.split('=')[0]
            if '@' in group: var = group.split('@')[0]
            if '=' not in group and '@' not in group: var = group
            var_missing = [var not in data[test] for test in tests]
            if all(var_missing):
                fatal_error([f'Variable {var} doesn\'t exist in the data file', f'Only the following variables are present: {set().union(*[set(data[test].keys()) for test in tests])}'])
            if any(var_missing):
                fatal_error(f'Variable {var} is missing from {sum(var_missing)} out of {len(tests)} tests')
            
            if '=' in group:
                group_names.append(group)
                group_tests.append(apply_filter(tests, data, group))
            elif '@' in group:
                bins = int(group.split('@')[1])
                values = sorted([data[test][var] for test in tests])
                # XXX: probably there's a better way to split values into bins
                pos_start = 0
                for bin in range(bins):
                    pos_end = (bin+1) * len(tests) // bins
                    while pos_end < len(tests) and values[pos_end] == values[pos_end-1]: pos_end += 1
                    if pos_end <= pos_start: 
                        continue
                    group_name = f'{var}={values[pos_start]}-{values[pos_end-1]}'
                    group_names.append(group_name)
                    group_tests.append(apply_filter(tests, data, group_name))
                    pos_start = pos_end
            else:
                var_set = sorted(set([data[test][var] for test in tests]))
                for value in var_set:
                    group_names.append(f'{var}={value}')
                    group_tests.append(apply_filter(tests, data, f'{var}={value}'))
                    
--groups 처리: 변수 누락을 검사한 뒤, '=' 범위면 단일 그룹, '@N'이면 N개 균등 빈으로 분할, 그 외엔 값마다 그룹을 만듭니다.
    # generate data for each column
    columns = {}
    columns['runs'] = [('Tests\nRun', [run_name for run_name in runs])]
    columns['groups'] = []

    max_cells = []

    precision = int(cfg["general"]["precision"])
        
    # TODO: speed up (find the bottleneck, maybe try numpy?)

    total_fails = {run_name: 0 for run_name in runs}
    total_bests = {run_name: 0 for run_name in runs}
    total_uniques = {run_name: 0 for run_name in runs}
    total_gain = {run_name: 0 for run_name in runs}
    total_missing = {run_name: 0 for run_name in runs}
열별 데이터 컨테이너와 누적 카운터(fails·bests·uniques·gain·missing)를 준비합니다.
    for group_no, (group_name, group_test) in enumerate(zip(group_names, group_tests)):
        total_scores = {run_name: 0 for run_name in runs}
        group_scale = args.scale / max(1, len(group_test)) if args.scale else 1.0
        for test in group_test:
            scores = process_raw_scores([run_results[test].get(args.var, 0) for run_results in runs.values()], args.scoring)
            best_score = max(scores)
            second_best_score = sorted(scores)[-2] if len(scores) > 1 else 0
            unique_best = len([score for score in scores if score == best_score]) == 1
각 그룹을 돌며 점수를 합산. process_raw_scores로 상대화한 뒤 그룹 내 최고/2위/유일최고 여부를 판정합니다.
            for run_name, score in zip(runs.keys(), scores):
                total_scores[run_name] += score
                if group_no == 0:
                    total_bests[run_name] += 1 if score == best_score else 0
                    total_uniques[run_name] += 1 if score == best_score and unique_best else 0
                    total_gain[run_name] += max(0, score - second_best_score) * group_scale
                    total_fails[run_name] += 1 if score <= 0 else 0
                    total_missing[run_name] += 0 if args.var in runs[run_name][test] else 1

                    
각 실행의 점수를 누적하고, Overall 그룹(group_no==0)에서만 bests·uniques·gain·fails·missing을 집계합니다. (Gain = 1위−2위 차이의 합, 즉 그 실행만의 고유 기여)
        column = (f'{len(group_test)}\n{group_name}', [total_scores[run_name] * group_scale for run_name in runs])
그룹 한 열의 값들을 만듭니다(scale 반영).
        # mark the position of the best score in the group
        best_score = max(total_scores.values())
        for i, score in enumerate(total_scores.items()):
            if score[1] == best_score:
                max_cells.append((column[0], i))
                # column[1][i] = group_max_cmd + str(column[1][i]) + reset_cmd + (even_row_cmd if i % 2 else odd_row_cmd)

        if group_no == 0:
            columns['overall'] = [column]
        else:
            columns['groups'].append(column)
     
각 열에서 최고 점수 셀의 위치를 기록해 둡니다 — 나중에 초록색으로 강조하려고.
    columns['bests'] = [('\nBests', [total_bests[run_name] for run_name in runs])]
    columns['uniques'] = [('\nUniques', [total_uniques[run_name] for run_name in runs])]
    columns['gain'] = [('\nGain', [total_gain[run_name] for run_name in runs])]
    columns['fails'] = [('\nFails', [total_fails[run_name] for run_name in runs])]
    columns['missing'] = [('\nMissing', [total_missing[run_name] for run_name in runs])]
    
요약 열(Bests·Uniques·Gain·Fails·Missing)을 구성합니다.
    if all([v > 0 for v in total_missing.values()]):
        fatal_error(f'None of the results files contain "{args.var}" variable')
모든 결과 파일에 해당 변수 자체가 없으면 오류.
    leaderboard = cfg['general']['leaderboard_score'] if args.var == 'score' else cfg['general']['leaderboard_custom']
    leaderboard = 'runs,' + leaderboard
    headers = []
    table = []

    # TODO: show error if it's not avg,max,min,sum?
    # TODO: add an ability to add alias with = so the final format would FUN:VAR.X=ALIAS
    # TODO: rewrite this part since it's a complete mess
cfg의 leaderboard 정의로 열 순서를 정합니다(score면 leaderboard_score, 그 외엔 leaderboard_custom).
    # generate var columns
    all_vars = [(column_name.split(':')[0].lower(), column_name.split(':')[1].split('.')[0]) for column_name in leaderboard.split(',') if ':' in column_name and column_name.split(':')[0].lower() in ['avg','min','max','sum','gavg']]
    for fun, var in all_vars:
        column = []
        for run_results in runs.values():
            if not any([var in run_results[test] for test in tests]):
                column.append(None)
                continue
            data = [run_results[test][var] for test in tests if var in run_results[test]]
            data = [value for value in data if value is not None]
            if fun == 'gavg':
                data = [x for x in data if x > 0]
            fun_mapping = {
                'sum': sum,
                'min': min,
                'max': max,
                'avg': lambda a: sum(a) / len(tests),
                'gavg': lambda a: math.exp(sum(math.log(x) for x in a) / len(a))
            }
            column.append(fun_mapping[fun](data) if data else '')
        columns[f'{fun}:{var}'] = [(f'\n{var}', column)]
AVG/MIN/MAX/SUM/GAVG:변수 형식의 집계 열을 생성합니다(gavg는 0 초과 값들의 기하평균).
    for column_name in leaderboard.split(','):
        column_name = column_name.lower() if not column_name.lower().startswith('var:') else 'var:' + column_name[4:]
        optional = False
        column_precision = precision
        if column_name[-1] == '?':
            optional = True
            column_name = column_name[:-1]
        if column_name[-2] == '.' and column_name[-1].isdigit():
            column_precision = int(column_name[-1])
            column_name = column_name[:-2]
        if column_name not in columns:
            fatal_error(f'Unknown column name: {column_name}, please correct the leaderboard_XXX option')
        for column in columns[column_name]:
            if not optional or any(column[1]):
                data = column[1]
                data = [round(value, column_precision if column_precision > 0 else None) if value is not None and isinstance(value, numbers.Number) else value for value in data]
                for col_header, row in max_cells:
                    if col_header == column[0]:
                        data[row] = group_max_cmd + str(data[row]) + reset_cmd + (even_row_cmd if row % 2 else odd_row_cmd)
                headers.append(column[0])
                table += [data]
                
leaderboard 항목을 파싱: '?'(값 없으면 열 숨김)·'.N'(자릿수)을 처리하고, 값을 반올림하고, 최고 셀에 색 코드를 끼워 넣어 헤더와 표 데이터를 채웁니다.
    table = list(zip(*table))
    if hasattr(tabulate, 'MIN_PADDING'):
        tabulate.MIN_PADDING = 0    
열 단위 데이터를 행 단위로 전치하고 tabulate의 최소 패딩을 0으로 설정합니다.
    # generate ascii table and output it using colors
    output = tabulate.tabulate(table, headers=headers)
    parity = False
    header = True
    for line in output.splitlines():
        if header:
            if line.startswith('-'):
                header = False
            else:
                line = header_cmd + line + reset_cmd
        else:
            line = (even_row_cmd if parity else odd_row_cmd) + line + reset_cmd
            parity = not parity
        print(line)
tabulate로 ASCII 표를 만든 뒤, 헤더 줄과 짝/홀수 데이터 줄에 색 명령을 입혀 한 줄씩 출력합니다 — 지브라 줄무늬 + 헤더색의 정체.
def _main()진입점 · 인자 파싱 · 모드 분기L454–742
def _main():
    global args
    global cfg
    
프로그램의 진입점. 전역 args/cfg를 채우고 argparse로 모드·옵션을 파싱한 뒤 모드별로 동작합니다.
    parser = argparse.ArgumentParser(description='Local tester for Topcoder Marathons & AtCoder Heuristic Contests\nMore help available at https://github.com/FakePsyho/psytester', formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument('-c', '--config', type=str, default=DEFAULT_CONFIG_PATH, help='path to cfg file')
    parser.set_defaults(mode=None)
    subparsers = parser.add_subparsers(title='modes')
    
최상위 파서와 공통 인자(-c/--config)를 정의하고 서브커맨드(modes)를 등록할 준비를 합니다.
    parser_args_tests = argparse.ArgumentParser(add_help=False)
    parser_args_tests.add_argument('-t', '--tests', type=str, help='number of tests to run (seeds 1-N), range of seeds (e.g. A-B) or the name of the JSON/text file with the list of seeds')
    
    parser_args_data = argparse.ArgumentParser(add_help=False)
    parser_args_data.add_argument('--data', type=str, default=None, help='file with metadata, used for grouping and filtering; in order to always use latest results file set it to LATEST') 
    parser_args_data.add_argument('--filters', type=str, default=None, nargs='+', metavar='FILTER', help='filters results based on the provided criteria in the form of VAR=A-B') 
    parser_args_data.add_argument('--dir', type=str, default=None, help='directory where the results files are located')
여러 모드가 공유하는 인자 그룹: run/show가 쓰는 --tests, show/find가 쓰는 --data/--filters/--dir.
    parser_run = subparsers.add_parser('run', aliases=['r'], parents=[parser_args_tests], help='runs a batch of tests')
    parser_run.set_defaults(mode='run')
    parser_run.add_argument('-m', '--threads_no', type=int, help='number of threads to use') 
    parser_run.add_argument('-p', '--progress', action='store_true', help='shows current progress when testing') 
    parser_run.add_argument('-d', '--debug', action='store_true', help='special verbose mode helpful when figuring out why psytester doesn\'t work; overrides threads_no to 1')
    parser_run.add_argument('-a', '--tester_arguments', type=str, default='', help='additional arguments for the tester')
    parser_run.add_argument('-r', '--retry', type=int, default=0, help='number of retries when a test fails')
    parser_run.add_argument('name', type=str, nargs='?', default=None, help='name of the run; if not specified the results will be printed to stdout') 
    
run 모드 인자: 스레드 수(-m), 진행표시(-p), 디버그(-d), 재시도(-r), 실행 이름(name).
    parser_show = subparsers.add_parser('show', aliases=['s'], parents=[parser_args_tests, parser_args_data], help='shows current results', formatter_class=argparse.RawTextHelpFormatter)
    parser_show.set_defaults(mode='show')
    parser_show.add_argument('--groups', type=str, nargs='+', default=None, metavar='GROUP', help='create additional columns for each group based on the provided criteria\nFormats allowed:\nVAR     - create a single group for each value of VAR\nVAR@N   - create N equal-sized based on VAR\nVar=A-B - create a single group where VAR is within A-B range') 
    parser_show.add_argument('--var', type=str, default='score', help='name of the variable you want to visualize (instead of score)')
    parser_show.add_argument('--scoring', type=str, default=None, choices=['raw','min', 'max'], help='sets the scoring function used for calculating ranking')
    parser_show.add_argument('--sorting', type=str, default=None, choices=['name', 'date'], help='sets how the runs are sorted')
    parser_show.add_argument('--files', type=str, nargs='+', default=None, help='list of regexp patterns for files to be included')
    parser_show.add_argument('--xfiles', type=str, nargs='+', default=None, help='list of regexp patterns for files to be excluded')
    parser_show.add_argument('--scale', type=float, default=None, help='sets scaling of results') 
    parser_show.add_argument('--noscale', action='store_true', help='turns off the scaling; values will be a simple sum over all tests') 
    
show 모드 인자: --groups/--var/--scoring/--sorting/--files/--scale 등.
    parser_find = subparsers.add_parser('find', aliases=['f'],  parents=[parser_args_tests, parser_args_data], help='sorts the results based on specified critieria')
    parser_find.set_defaults(mode='find')
    parser_find.add_argument('--var', type=str, default='score', help='name of the variable you want to sort by')
    parser_find.add_argument('--order', type=str, default='-', choices=['-', '+'], help='whether the tests should be sorted descending ("-") or ascending ("+")')
    parser_find.add_argument('--limit', type=int, default=None, help='limits the number of tests to print') 
    
find 모드 인자: 정렬 기준(--var), 방향(--order), 개수 제한(--limit).
    parser_config = subparsers.add_parser('config', aliases=['c'], help='loads/saves/deletes specified template config')
    parser_config.set_defaults(mode='config')
    config_mode_group = parser_config.add_mutually_exclusive_group(required=True)
    config_mode_group.add_argument('--load', type=str, metavar='TEMPLATE', help='creates a new config based on specified template config')
    config_mode_group.add_argument('--save', type=str, metavar='TEMPLATE', help='updates a template config with local config')
    config_mode_group.add_argument('--delete', type=str, metavar='TEMPLATE', help='permanently deletes stored template config')
    config_mode_group.add_argument('--list', action='store_true', help='lists available template configs')
    
config 모드 인자: --load/--save/--delete/--list (서로 배타적).
    args = parser.parse_args()

    if args.mode is None:
        fatal_error('No mode specified, type "psytester -h" for help')
    
인자를 파싱합니다. 모드를 안 주면 도움말 안내 후 종료.
    if args.mode == 'config':
        if args.load:
            template = args.load + '.cfg'
            template_config = os.path.join(os.path.dirname(__file__), template)
            if not os.path.exists(template_config):
                fatal_error(f'Missing {template} template config file')
            if os.path.exists(args.config):
                fatal_error(f'Config file {args.config} already exists')
            print(f'Creating new config file at {args.config}')
            shutil.copy(template_config, os.path.join(os.getcwd(), args.config))
            sys.exit(0)
            
        elif args.save:
            template = args.save + '.cfg'
            template_config = os.path.join(os.path.dirname(__file__), template)
            if os.path.exists(template_config):
                fatal_error(f'Template config file {args.save} already exists; if you wish to update it, you have to delete it first')
config 모드 처리: 패키지 내부의 템플릿 .cfg와 현재 폴더 사이에서 설정 파일을 복사(load)·저장(save)·삭제(delete)하거나 목록(list)을 보여줍니다.
            if not os.path.exists(args.config):
                fatal_error(f'Config file {args.config} doesn\'t exist')
            print(f'Updating {args.save} template config with {args.config}')
            shutil.copy(os.path.join(os.getcwd(), args.config), template_config)
            sys.exit(0)
            
        elif args.delete:
            template = args.delete + '.cfg'
            template_config = os.path.join(os.path.dirname(__file__), template)
            if not os.path.exists(template_config):
                fatal_error(f'Missing {template} template config file')
            print(f'Removing template config file {template}')
            os.remove(template_config)
            sys.exit(0)
            
        elif args.list:
            template_configs = glob.glob(f'{os.path.dirname(__file__)}/*.cfg')
            table = []
            for template_config in template_configs:
이후 모드는 tester.cfg가 필요 — 없으면 만드는 법을 안내하고 종료.
                cfg = configparser.ConfigParser()
                cfg.read(template_config)
                table += [[os.path.splitext(os.path.basename(template_config))[0], cfg['general']['description']]]
            print('Available template config files:')
            print(tabulate.tabulate(table, headers=['name', 'description']))
            sys.exit(0)
            
        
    if not os.path.exists(args.config):
        fatal_error([f"Missing config file {args.config}, either use correct config file with \"psytester -c config_file\" or create a new one with \"psytester config --load template\"",
            "If you don't know how to use psytester, please check out the github project readme at: https://github.com/FakePsyho/psytester"])
    
    cfg = configparser.ConfigParser(interpolation=None)
    cfg.read(args.config)
    
    if cfg['general']['version'] != __version__:
        fatal_error([f"{args.config} version ({cfg['general']['version']}) doesn't match the current version of psytester {__version__}",
            "Unfortunately psytester is currently not backwards compatible with old config files",
            "The recommended way to resolve this problem is to manually update your config file with changes introduced in the new version (create a new config file with \"psytester config --load template\")",
            "Alternatively, you can downgrade your version of psytester to match the config file"])
    
    # XXX: probably there's a better way to do this
설정 파일을 로드합니다. 파일의 버전이 현재 psytester와 다르면(하위호환 X) 갱신/다운그레이드 방법을 안내하고 종료.
    def convert(value, type=str):
        if value is None or value == '':
            return None
        
        if type == bool:
            return value.lower() in ['true', 'yes']
        return type(value)
        
    
cfg 문자열을 원하는 타입으로 바꾸는 내부 헬퍼(빈 값→None, bool 처리).
    # Parse args.tests
    args.tests = try_str_to_numeric(args.tests or convert(cfg['default']['tests']))
    if args.tests is None:
        pass
    elif isinstance(args.tests, int):
        args.tests = list(range(1, args.tests + 1))
    elif re.search('[a-zA-Z]', args.tests):
        if not os.path.exists(args.tests):
            fatal_error(f'Cannot locate {args.tests} file')

        with open(args.tests) as f:
            lines = f.read().splitlines()
        assert len(lines) > 0
        
        if isinstance(try_str_to_numeric(lines[0]), int):
            args.tests = [int(line) for line in lines]
        else:
            args.tests = [json.loads(line)['id'] for line in lines]
    else:
        assert '-' in args.tests
        lo, hi = args.tests.split('-')
        lo = try_str_to_numeric(lo)
        hi = try_str_to_numeric(hi)
        args.tests = list(range(lo, hi + 1))
    
    # Mode: Find
--tests 해석: 정수 N이면 시드 1..N, “A-B”면 범위, 글자가 섞인 문자열이면 시드 목록 파일(텍스트 또는 JSON)을 읽습니다.
    if args.mode == 'find':
        args.data = args.data or cfg['default']['data']
        if args.data == 'LATEST':
            results_files = find_res_files(args.dir or cfg['general']['results_dir'])
            _, args.data = sorted(zip([os.path.getmtime(result_file) for result_file in results_files], results_files))[-1]
        else:
            args.data += cfg['general']['results_ext']
        
        results = load_res_file(args.data)
        tests = results.keys()
        if args.tests:
            tests = list(set(tests) & set(args.tests))
        
        for filter in args.filters or []:
            tests = apply_filter(tests, results, filter)
            
        ordered_tests = [test for _, test in sorted(zip([results[test][args.var] for test in tests], tests), reverse=args.order == '-')]
        
        if args.limit:
            ordered_tests = ordered_tests[:args.limit]
        
        print(f'Finding in {args.data} file')
        for test in ordered_tests:
            print(json.dumps(results[test]))
        sys.exit(0)
        
    # Mode: Show
find 모드: 결과 파일을 로드(LATEST면 최신 자동 선택)하고 필터 적용 후 var 기준으로 정렬·limit하여 JSON으로 출력.
    if args.mode == 'show':
        args.data = args.data or cfg['default']['data']
        args.scale = args.scale or convert(cfg['default']['scale'], float)
        if args.noscale:
            args.scale = None
        args.scoring = args.scoring or convert(cfg['default']['scoring'])
        args.sorting = args.sorting or convert(cfg['default']['sorting'])
        
        results_files = find_res_files(args.dir or cfg['general']['results_dir'], args.files, args.xfiles)
        if not results_files:
            fatal_error(f'There are no results files in the results folder: {cfg["general"]["results_dir"]}')
            
        if args.sorting == 'name':
            results_files = sorted(results_files)
        elif args.sorting == 'date':
            results_files = [result_file for _, result_file in sorted(zip([os.path.getmtime(result_file) for result_file in results_files], results_files))]
            
        results = {os.path.splitext(os.path.basename(file))[0]: load_res_file(file) for file in results_files}
        
        if args.data == 'LATEST':
            _, args.data = sorted(zip([os.path.getmtime(result_file) for result_file in results_files], results_files))[-1]
        data_file = load_res_file(args.data) if args.data and os.path.isfile(args.data) else None
        
        show_summary(results, tests=args.tests, data=data_file, groups=args.groups, filters=args.filters)
        sys.exit(0)

    # Mode: Run
show 모드: 기본값을 채우고 결과 파일들을 수집·정렬, 데이터 파일을 로드한 뒤 show_summary를 호출합니다.
    if args.mode == 'run':
        args.threads_no = args.threads_no or convert(cfg['default']['threads_no'], int)
        args.progress = args.progress or convert(cfg['default']['progress'], bool)
        args.tester_arguments = args.tester_arguments or cfg['default']['tester_arguments'] 
        if args.debug:
            args.threads_no = 1
            args.progress = False
        
        if not os.path.exists(cfg['general']['tests_dir']):
            os.mkdir(cfg['general']['tests_dir'])
            
        if not args.tests:
            fatal_error('You need to specify tests to run, use --tests option')
        
        assert args.threads_no >= 1
        fout = sys.stdout
        if args.name:
            os.makedirs(cfg["general"]["results_dir"], exist_ok=True)
            fout = open(f'{cfg["general"]["results_dir"]}/{args.name}{cfg["general"]["results_ext"]}', 'w')
            
        global patterns
        for s in cfg['general']:
            if (s.startswith('extraction_regex_')):
                patterns.append(cfg['general'][s])
        if not patterns:
            fatal_error('No extraction patterns specified (introduced in psytester 0.5.0) - check online documentation')
        if args.debug:
            print('Extraction patterns found:')
            for pattern in patterns:
                print(pattern)
            print()
        
run 모드: 기본값/스레드 수를 검증하고 출력 폴더·결과 파일을 준비, extraction_regex_* 패턴들을 모읍니다.
        try:
            start_time = time.time()
            for id in args.tests:
                tests_queue.put({'seed': id})
            tests_left = args.tests
            
모든 시드를 작업 큐에 넣고 worker 스레드들을 생성합니다.
            def worker_loop():
                while True:
                    try:
                        seed = tests_queue.get(False)
                        result = run_test(seed)
                        results_queue.put(result)
                        if args.debug:
                            time.sleep(0.1)
                    except queue.Empty:
                        return
                    except:
                        traceback.print_exc()
                        fatal_error('One of the worker threads encountered an error', exit_main=True);
            
각 스레드의 루프: 큐에서 시드를 꺼내 run_test 실행, 결과를 결과 큐에 넣습니다. 큐가 비면 종료, 예외면 전체 중단.
            workers = [Thread(target=worker_loop) for _ in range(args.threads_no)]
            for worker in workers:
                worker.start()
            
            sum_scores = 0
            log_scores = 0
            results = {}
            processed = 0
            
지정한 개수만큼 스레드를 만들어 시작합니다.
            while tests_left:
                result = results_queue.get()
                results[result['id']] = result
                assert result['id'] in tests_left
                while tests_left and tests_left[0] in results:
                    processed += 1
                    seed = tests_left[0]
                    print(json.dumps(results[seed]), file=fout, flush=True)
                    tests_left = tests_left[1:]
                    sum_scores += results[seed]['score'] if results[seed]['score'] > 0 else 0
                    log_scores += math.log(results[seed]['score']) if results[seed]['score'] > 0 else 0
                    if args.progress and args.name:
                        output = f'Progress: {processed} / {processed+len(tests_left)}   Time: {time.time() - start_time : .3f}'
                        print(f'\r{output}                       ', end='', file=sys.stderr)
                        sys.stderr.flush()
                        time.sleep(0.002)
결과를 모아 “시드 순서대로” .res에 기록하고, 점수 합계·로그 합계를 계산하며, 진행 상황을 \r로 덮어쓰며 표시합니다.
        except KeyboardInterrupt:
            print('\nInterrupted by user', file=sys.stderr)
            os._exit(1)
            
        print(file=sys.stderr)
            
Ctrl+C가 들어오면 즉시 종료.
        print("Time:", time.time() - start_time, file=sys.stderr)
        print("Avg Score:", sum_scores / len(results), file=sys.stderr)
        print("Avg Log Scores:", log_scores / len(results), file=sys.stderr)
마지막에 총 소요시간·평균 점수·평균 로그점수를 stderr로 출력합니다.
06 설정 · 기타 파일 · CONFIG & REST

동작을 지배하는 .cfg, 그리고 보조 파일

tester.cfg (topcoder 템플릿)동작을 결정하는 설정
[general]
# version, don't edit this unless you're trying to manually update the config file to a newer version of mmtester
version = 0.5.2

# description of the config
description = Topcoder Marathon problems; extracts both score and execution time

# dir where result files are saved
results_dir = .

# dir where outputs are saved
tests_dir = tests

# if set to False all outputs are saved to separate folders (tests_dir/run_name/), if set to True, they are saved to the same folder (tests_dir/)
merge_output_dirs = False

# extension for result files
results_ext = .res

# whether keep the outputs
keep_output_files = True

# number of precision digits used in scoring
precision = 4

# whether to use colors in the leaderboard
show_colors = True 
# allowed colors: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, DEFAULT (DEFAULT means using default terminal color)
# you can also use BRIGHT_ and DIM_ prefixes (e.g. BRIGHT_RED) to make the color brighter/darker (note that DIM_ doesn't work in most terminals)
header_color = BRIGHT_DEFAULT
even_row_color = YELLOW
odd_row_color = DEFAULT
# for group_max_color, you can additionally use the following values: BRIGHT_*, DIM_*, *_COLOR (where COLOR is one of the colors above); those will only update specified style or color without affecting the other
group_max_color = GREEN

# show: defines the order of columns in the leaderboard
# leaderboard_score is the default one used for score
# leaderboard_custom is used when supplied with --var 
# OVERALL = overall score
# GROUPS = all columns related to --groups
# BESTS = number of times the result achieved the best result
# UNIQUES = number of unique (not achieved by any other result) bests
# GAIN = contribution towards the relative bests (sum for each unique best result - second best result)
# FAILS = number of fails 
# MISSING = number of missing values
# FUN:NAME = FUN has to be one of SUM,AVG,MIN,MAX; shows sum/average/min/max value of NAME, where NAME is case-sensitive data contained in results (e.g. AVG:score)
# you can add ? after the name (for example FAILS?) so that column will be added only if it has any non-zero values 
leaderboard_score = OVERALL,GROUPS,BESTS,UNIQUES,GAIN,FAILS?,MISSING?
leaderboard_custom = OVERALL,GROUPS,FAILS?,MISSING?

# commands used to run / generate tests, check github project page for more information
cmd_tester = java -jar tester.jar -novis -saveSolError %OUTPUT_DIR% -no -pr -exec "a.exe" -seed %SEED% %TESTER_ARGS% > %OUTPUT_DIR%/%SEED%.out
output_files = %OUTPUT_DIR%/%SEED%.out,%OUTPUT_DIR%/%SEED%.err

# regex patterns used for extracting information (score, time & user-defined metadata) from output files
# when analyzing output, every line is matched vs every pattern (you can add new patterns by adding options that start with "extraction_regex_")
# in order to extract variables you need to define named catch group with the variable name
# alternatively there are two special names: VARIABLE and VALUE that you can use for extracting both name and the value, so that you don't have to create a separate pattern for each value you want to add
# the strings are imported as raw, so there's no need for double-escaped characters 
extraction_regex_0 = ^\s*\[DATA\]\s+(?P<VARIABLE>[a-zA-Z]\w*)\s*=\s*(?P<VALUE>\S+)\s*$
extraction_regex_1 = ^\s*Score\s*=\s*(?P<score>[-+]?\d*\.\d+|\d+),\s*RunTime\s*=\s*(?P<time>[-+]?\d*\.\d+|\d+)\s*ms\s*$


[default]
# type of scoring: raw (sum of absolute scores), min (relative, lower = better), max (relative, higher = better)
scoring = max

# number of concurrent runs 
threads_no = 3

# whether to show current progress
progress = True

# either number of tests (seeds 1-N), range of seeds (A-B) or the name of the file that stores seeds in plain text (1 seed per line) or JSON format used in results files
tests = 

# not sure why it's useful, but it's here
tester_arguments = 

# path to file with metadata (including extension); set it to LATEST if you always want to use latest res file
data = LATEST

# scales scores so that max is equal to scale no matter the number of tests, leave it empty to just show the sum
scale = 100.0

# how to sort results files: name (alphabetically), date (oldest to newest)
sorting = name
tester.cfg 구조 — INI 형식, [general](불변 동작)과 [default](실행 기본값)로 나뉨.
역할
cmd_tester채점기를 실행하는 셸 명령(토큰 치환)
output_files점수를 긁어올 출력 파일 목록
extraction_regex_*점수·메타데이터 추출 정규식
*_color헤더·짝/홀수 행·최고점 색
leaderboard_*스코어보드 열 순서 정의
scoringraw / min / max
threads_no · scale병렬 스레드 수 · 점수 정규화 배율
setup.py패키지 정의·진입점
from setuptools import setup
__version__ = '0.5.2'

setup(
    name = 'psytester',
    packages = ['psytester'],
    package_data = {'psytester': ['topcoder.cfg', 'old_topcoder.cfg', 'atcoder.cfg']},
    version = __version__,
    license = 'MIT',
    description = "Local tester for Topcoder Marathons & AtCoder Heuristic Contests",
    author = 'Psyho',
    url = 'https://github.com/FakePsyho/psytester',
    keywords = ['Topcoder', 'Marathon', 'AtCoder', 'Competitive Programming', 'Tester'],
    install_requires=['tabulate', 'colorama'],
    classifiers = [
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Developers',
        'Topic :: Scientific/Engineering',
        'Environment :: Console',
        'License :: OSI Approved :: MIT License',
        'Operating System :: OS Independent',
        'Programming Language :: Python :: 3',
    ],
    entry_points = {'console_scripts': ['psytester = psytester.tester:_main']},
)
패키지 정의 · 진입점
  • entry_pointspsytester = psytester.tester:_main — 터미널의 psytester 명령이 곧 tester.py_main()입니다.
  • package_data로 세 개의 템플릿 .cfg를 패키지에 동봉 — config 모드가 이걸 복사합니다.
  • install_requires: tabulate, colorama.
update_version.py버전 일괄 변경(개발용)
import os
import re
import sys
import glob

assert len(sys.argv) == 2
version = sys.argv[1]

assert re.match(r'^[0-9]+\.[0-9]+\.[0-9]+', version)

for path in glob.glob('psytester/*.cfg') + ['psytester/tester.py'] + ['setup.py']:
    with open(path, 'r') as f:
        lines = f.readlines()

    for i, line in enumerate(lines):
        if line.startswith('version'):
            lines[i] = f'version = {version}\n'
            break
        if line.startswith('__version__'):
            lines[i] = f'__version__ = \'{version}\'\n'
            break

    with open(path, 'w') as f:
        f.writelines(lines)
    

버전 일괄 변경 스크립트(개발용) — 인자로 받은 버전을 모든 .cfg·tester.py·setup.py의 version 줄에 한 번에 써넣습니다. 릴리스 보조 도구라 런타임과는 무관.