Repository: kmyk/online-judge-tools
Pull request #233 (2019-01-03 10:25:37)

[WIP] oj testでディレクトリ指定できないバグ修正
/home/ryo/work/github/rust-debug/src/
というディレクトリにおいて
oj test -c ./a.out -d ./testを行うと、
```
Traceback (most recent call last):
  File "/home/ryo/.virtualenvs/default/bin/oj", line 6, in <module>
    exec(compile(open(__file__).read(), __file__, 'exec'))
  File "/home/ryo/work/github/online-judge-tools/oj", line 6, in <module>
    onlinejudge.implementation.main.main(args=sys.argv[1:])
  File "/home/ryo/work/github/online-judge-tools/onlinejudge/implementation/main.py", line 345, in main
    run_program(namespace, parser=parser)
  File "/home/ryo/work/github/online-judge-tools/onlinejudge/implementation/main.py", line 320, in run_program
    test(args)
  File "/home/ryo/work/github/online-judge-tools/onlinejudge/implementation/command/test.py", line 41, in test
    tests = cutils.construct_relationship_of_files(args.test, args.directory, args.format)
  File "/home/ryo/work/github/online-judge-tools/onlinejudge/implementation/command/utils.py", line 52, in construct_relationship_of_files
    m = match_with_format(directory, format, path.resolve())
  File "/home/ryo/work/github/online-judge-tools/onlinejudge/implementation/command/utils.py", line 27, in match_with_format
    path = path.absolute().relative_to(directory.absolute()).resolve()
  File "/usr/lib/python3.5/pathlib.py", line 1109, in resolve
    s = self._flavour.resolve(self)
  File "/usr/lib/python3.5/pathlib.py", line 330, in resolve
    return _resolve(base, str(path)) or sep
  File "/usr/lib/python3.5/pathlib.py", line 315, in _resolve
    target = accessor.readlink(newpath)
  File "/usr/lib/python3.5/pathlib.py", line 422, in readlink
    return os.readlink(path)
FileNotFoundError: [Errno 2] No such file or directory: '/home/ryo/work/github/rust-debug/src/sample-1.in'

```
というエラーが発生します。
私がローカルで修正を試みたパッチを添えておきます。
実行環境: Python 3.5、https://atcoder.jp/contests/arc087/tasks/arc087_b の問題をダウンロード


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 1019769..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Contribute Guide / 開発を手伝ってくれる人へ
-
-## language / 言語について
-
-In the source code and documents for end users, we should use English.
-For other place, both English and Japanese are acceptable.
-
-ソースコード中とエンドユーザが目にする部分はすべて英語で書いてください。
-それ以外の部分は英語でも日本語でもかまいません。
-
-## issues / issue について
-
-機能要求やバグ報告は気軽にしてください。
-コードを書くことだけが開発ではありません。
-
-## pull requests / プルリクについて
-
-基本的にはどんなものでも歓迎します。
-
-ただし常にそのまま merge されるとは限らないので注意してください。
-なにかまずいところがあっても修正を要求するだけなので質は問いません (なにか分からないところがあればとりあえずできたところまでで投げてくれてもよいです) が、以下に従っておくと merge されるまでの時間は短くなるでしょう。
-
--   手元でテストをする
-    -   CI が通らない限りは merge はできません
--   コーディングスタイルを周囲のコードと合わせる
--   変更箇所は必要最低限にする
--   commit は適切に分割する
--   機能追加をする場合は事前に確認をする
-    -   「そういう機能は入れません」で弾かれてせっかくの実装が無駄になるとお互いに不幸なためです
-
-
-# Internal Structure / 内部構造
-
-## philosophy of design / 設計の方針
-
-第一義は「コンテストで上位を取ることに役立つこと」です。
-特に「ペナルティを出させないこと」に注力しています。
-これを実現する手段として「手動だと間違えたりさぼったりしやすい作業を自動化する」を用いています。
-
-Web scraping をする性質により動作は必然的に不安定であり、これは「ペナルティを出させないこと」の壁となります。
-これへの対応として「誤動作の起こりやすい機能は避ける」「誤動作があったときに誤動作があると気付きやすいようにする」などを重要視しています。
-その実践の例としては「取得したサンプルケースを(ファイルに出力するだけでなく)画面に見やすく表示する」が分かりやすいでしょう。
-そのような画面への出力がない場合、サンプルケースの取得ミスはかなりの時間のロスを引き起すことが知られています。
-
-## module structure
-
-主に以下のような構造です。
-
--   `onlinejudge/`
-    -   `type.py`: 型はすべてここ
-    -   `dispatch.py`: URL から object を解決する仕組み
-    -   `implementation/`
-        -   `main.py`: 個別のコマンドを呼び出すまでの部分
-        -   `command/`: `download` `submit` などのコマンドの本体が置かれる
-            -   `download.py`
-            -   `submit.py`
-            -   ...
-    -   `service/`: AtCoder, Codeforces などのサービスごとの実装が置かれる
-        -   `atcoder.py`
-        -   `codeforces.py`
-        -   ...
--   `tests/`: テストが置かれる
-
-## CI
-
-`master` `develop` に関する commit や pull request について CI が走ります。
-型検査とテストの実行を Python 3.5 と 3.6 のふたつについて確認しています。
-
-実行される内容は次の3行なので、これを手元で試せばだいたい同じ結果が得られます。
-あるいは `[WIP]` などと書いて pull request をするのでもよいでしょう。
-
-``` sh
-$ mypy --verbose --ignore-missing-imports oj onlinejudge/**/*.py
-$ make -C docs html
-$ python setup.py test
-```
-
-## deployment
-
-Travis CI から PyPI 上へ upload を仕掛けるように設定されています。
-
-手順:
-
-1.  `onlinejudge/__about__.py` 中の `__version_info__` の値を bump して commit する
-    -   このとき同時に `CHANGELOG.md` も修正する
-    -   例: [3a24dc](https://github.com/kmyk/online-judge-tools/commit/3a24dc64b56d898e387dee56cf9915be3ab0f7e2)
-2.  `v0.1.23` の形で Git tag を打って GitHub 上へ push する
-    -   これにより Travis CI の機能が呼び出され PyPI への upload がなされる
diff --git a/onlinejudge/implementation/command/generate_output.py b/onlinejudge/implementation/command/generate_output.py
index b0b3139..b68c4e2 100644
--- a/onlinejudge/implementation/command/generate_output.py
+++ b/onlinejudge/implementation/command/generate_output.py
@@ -13,7 +13,7 @@ def generate_output(args: 'argparse.Namespace') -> None:
         args.test = cutils.glob_with_format(args.directory, args.format) # by default
     if args.ignore_backup:
         args.test = cutils.drop_backup_or_hidden_files(args.test)
-    tests = cutils.construct_relationship_of_files(args.test, args.directory, args.format)
+    tests = cutils.construct_relationship_of_files(args.test, args.format)
     for name, it in sorted(tests.items()):
         log.emit('')
         log.info('%s', name)
@@ -31,7 +31,7 @@ def generate_output(args: 'argparse.Namespace') -> None:
             log.info('skipped.')
             continue
         log.emit(log.bold(answer.decode().rstrip()))
-        name = cutils.match_with_format(args.directory, args.format, it['in']).groupdict()['name']  # type: ignore
+        name = cutils.match_with_format(args.format, it['in']).groupdict()['name']  # type: ignore
         path = cutils.path_from_format(args.directory, args.format, name=name, ext='out')
         with path.open('wb') as fh:
             fh.write(answer)
diff --git a/onlinejudge/implementation/command/utils.py b/onlinejudge/implementation/command/utils.py
index edc43b8..82bec7c 100644
--- a/onlinejudge/implementation/command/utils.py
+++ b/onlinejudge/implementation/command/utils.py
@@ -19,13 +19,22 @@ def glob_with_format(directory: pathlib.Path, format: str) -> List[pathlib.Path]
         log.debug('testcase globbed: %s', path)
     return paths
 
-def match_with_format(directory: pathlib.Path, format: str, path: pathlib.Path) -> Optional[Match[str]]:
+def counts_included_dirname(format: str) -> int:
+    count = 0  # type: int
+    for dirname in format.split('/'):
+        if dirname:
+            count += 1
+    return count
+
+def match_with_format(format: str, path: pathlib.Path) -> Optional[Match[str]]:
     table = {}
     table['s'] = '(?P<name>.+)'
     table['e'] = '(?P<ext>in|out)'
-    pattern = re.compile('^' + utils.parcentformat(format, table) + '$')
-    path = path.absolute().relative_to(directory.absolute()).resolve()
-    return pattern.match(str(path))
+    format_root_path = path  # type: pathlib.Path
+    for i in range(counts_included_dirname(format)):
+        format_root_path = format_root_path.parent
+    pattern = re.compile('^' + str(format_root_path) + '/' + utils.parcentformat(format, table) + '$')
+    return pattern.match(str(path.resolve()))
 
 def path_from_format(directory: pathlib.Path, format: str, name: str, ext: str) -> pathlib.Path:
     table = {}
@@ -46,10 +55,10 @@ def drop_backup_or_hidden_files(paths: List[pathlib.Path]) -> List[pathlib.Path]
             result += [ path ]
     return result
 
-def construct_relationship_of_files(paths: List[pathlib.Path], directory: pathlib.Path, format: str) -> Dict[str, Dict[str, pathlib.Path]]:
+def construct_relationship_of_files(paths: List[pathlib.Path], format: str) -> Dict[str, Dict[str, pathlib.Path]]:
     tests = collections.defaultdict(dict)  # type: Dict[str, Dict[str, pathlib.Path]]
     for path in paths:
-        m = match_with_format(directory, format, path.resolve())
+        m = match_with_format(format, path.resolve())
         if not m:
             log.error('unrecognizable file found: %s', path)
             sys.exit(1)
diff --git a/onlinejudge/implementation/main.py b/onlinejudge/implementation/main.py
index 2ad8842..12a3eea 100644
--- a/onlinejudge/implementation/main.py
+++ b/onlinejudge/implementation/main.py
@@ -153,6 +153,7 @@ tips:
     subparser.add_argument('-i', '--print-input', action='store_true', help='print input cases if not AC')
     subparser.add_argument('--no-ignore-backup', action='store_false', dest='ignore_backup')
     subparser.add_argument('--ignore-backup', action='store_true', help='ignore backup files and hidden files (i.e. files like "*~", "\\#*\\#" and ".*") (default)')
+    subparser.add_argument('--json', action='store_true')
     subparser.add_argument('test', nargs='*', type=pathlib.Path, help='paths of test cases. (if empty: globbed from --format)')
 
     # generate scanner


Test patch:
diff --git a/onlinejudge/implementation/command/test.py b/onlinejudge/implementation/command/test.py
index de04526..f85d6d5 100644
--- a/onlinejudge/implementation/command/test.py
+++ b/onlinejudge/implementation/command/test.py
@@ -3,6 +3,7 @@ import onlinejudge
 import onlinejudge.implementation.utils as utils
 import onlinejudge.implementation.logging as log
 import onlinejudge.implementation.command.utils as cutils
+import json
 import math
 import sys
 import time
@@ -38,7 +39,7 @@ def test(args: 'argparse.Namespace') -> None:
         args.test = cutils.glob_with_format(args.directory, args.format)  # by default
     if args.ignore_backup:
         args.test = cutils.drop_backup_or_hidden_files(args.test)
-    tests = cutils.construct_relationship_of_files(args.test, args.directory, args.format)
+    tests = cutils.construct_relationship_of_files(args.test, args.format)
     if args.error: # float mode
         match = lambda a, b: compare_as_floats(a, b, args.error)
     else:
@@ -54,6 +55,7 @@ def test(args: 'argparse.Namespace') -> None:
     slowest_name = ''
     ac_count = 0
 
+    history = []  # type: List[Dict[str, Any]]
     for name, it in sorted(tests.items()):
         is_printed_input = not args.print_input
         def print_input():
@@ -71,11 +73,12 @@ def test(args: 'argparse.Namespace') -> None:
             begin = time.perf_counter()
             answer_byte, proc = utils.exec_command(args.command, shell=True, stdin=inf, timeout=args.tle)
             end = time.perf_counter()
+            elapsed = end - begin
             answer = answer_byte.decode()
-            if slowest < end - begin:
-                slowest = end - begin
+            if slowest < elapsed:
+                slowest = elapsed
                 slowest_name = name
-            log.status('time: %f sec', end - begin)
+            log.status('time: %f sec', elapsed)
             proc.terminate()
 
         # check TLE, RE or not
@@ -129,6 +132,21 @@ def test(args: 'argparse.Namespace') -> None:
             log.success(log.green('AC'))
             ac_count += 1
 
+        # push the result
+        testcase = {
+            'name': name,
+            'input': str(it['in'].resolve()),
+        }
+        if 'out' in it:
+            testcase['output'] = str(it['out'].resolve())
+        history += [ {
+            'result': result,
+            'testcase': testcase,
+            'output': answer,
+            'exitcode': proc.returncode,
+            'elapsed': elapsed,
+        } ]
+
     # summarize
     log.emit('')
     log.status('slowest: %f sec  (for %s)', slowest, slowest_name)
@@ -136,4 +154,9 @@ def test(args: 'argparse.Namespace') -> None:
         log.success('test ' + log.green('success') + ': %d cases', len(tests))
     else:
         log.failure('test ' + log.red('failed') + ': %d AC / %d cases', ac_count, len(tests))
+
+    if args.json:
+        print(json.dumps(history))
+
+    if ac_count != len(tests):
         sys.exit(1)
diff --git a/tests/command_test.py b/tests/command_test.py
new file mode 100644
index 0000000..a7cc3cb
--- /dev/null
+++ b/tests/command_test.py
@@ -0,0 +1,227 @@
+import unittest
+
+import contextlib
+import json
+import os
+import subprocess
+import sys
+import tempfile
+
+
+@contextlib.contextmanager
+def chdir(path):
+    cwd = os.getcwd()
+    try:
+        os.chdir(path)
+        yield
+    finally:
+        os.chdir(cwd)
+
+def run_in_sandbox(args, files):
+    ojtools = os.path.abspath('oj')
+    with tempfile.TemporaryDirectory() as tempdir:
+        with chdir(tempdir):
+            for f in files:
+                if os.path.dirname(f['path']):
+                    os.makedirs(os.path.dirname(f['path']), exist_ok=True)
+                with open(f['path'], 'w') as fh:
+                    fh.write(f['data'])
+                if f.get('executable', False):
+                    os.chmod(f['path'], 0o755)
+            proc = subprocess.run([ ojtools ] + args, stdout=subprocess.PIPE, stderr=sys.stderr)
+            return {
+                'proc': proc,
+                'tempdir': tempdir,
+            }
+
+
+class TestTest(unittest.TestCase):
+
+    def snippet_call_test(self, args, files, expected):
+        result = run_in_sandbox(args=[ '-v', 'test', '--json' ] + args, files=files)
+        self.assertTrue(result['proc'].stdout)
+        data = json.loads(result['proc'].stdout.decode())
+        self.assertEqual(len(data), len(expected))
+        for a, b in zip(data, expected):
+            self.assertEqual(a['testcase']['name'], b['testcase']['name'])
+            self.assertEqual(a['testcase']['input'], b['testcase']['input'] % result['tempdir'])
+            self.assertEqual('output' in a['testcase'], 'output' in b['testcase'])
+            if 'output' in b['testcase']:
+                self.assertEqual(a['testcase']['output'], b['testcase']['output'] % result['tempdir'])
+            self.assertEqual(a['exitcode'], b['exitcode'])
+            self.assertEqual(a['result'], b['result'])
+            self.assertEqual(a['output'], b['output'])
+
+    def test_call_test_simple(self):
+        self.snippet_call_test(
+            args=[ '-c', 'cat' ],
+            files=[
+                { 'path': 'test/sample-1.in', 'data': 'foo\n' },
+                { 'path': 'test/sample-1.out', 'data': 'foo\n' },
+                { 'path': 'test/sample-2.in', 'data': 'bar\n' },
+                { 'path': 'test/sample-2.out', 'data': 'foo\n' },
+            ],
+            expected=[ {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-1',
+                    'input': '%s/test/sample-1.in',
+                    'output': '%s/test/sample-1.out',
+                },
+                'output': 'foo\n',
+                'exitcode': 0,
+            }, {
+                'result': 'WA',
+                'testcase': {
+                    'name': 'sample-2',
+                    'input': '%s/test/sample-2.in',
+                    'output': '%s/test/sample-2.out',
+                },
+                'output': 'bar\n',
+                'exitcode': 0,
+            } ],
+        )
+
+    def test_call_test_select(self):
+        self.snippet_call_test(
+            args=[ '-c', 'cat', 'test/sample-2.in', 'test/sample-3.in', 'test/sample-3.out' ],
+            files=[
+                { 'path': 'test/sample-1.in', 'data': 'foo\n' },
+                { 'path': 'test/sample-1.out', 'data': 'Yes\n' },
+                { 'path': 'test/sample-2.in', 'data': 'bar\n' },
+                { 'path': 'test/sample-2.out', 'data': 'No\n' },
+                { 'path': 'test/sample-3.in', 'data': 'baz\n' },
+                { 'path': 'test/sample-3.out', 'data': 'No\n' },
+            ],
+            expected=[ {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-2',
+                    'input': '%s/test/sample-2.in',
+                },
+                'output': 'bar\n',
+                'exitcode': 0,
+            }, {
+                'result': 'WA',
+                'testcase': {
+                    'name': 'sample-3',
+                    'input': '%s/test/sample-3.in',
+                    'output': '%s/test/sample-3.out',
+                },
+                'output': 'baz\n',
+                'exitcode': 0,
+            } ],
+        )
+
+    def test_call_test_shell(self):
+        self.snippet_call_test(
+            args=[ '-c', './build/foo.sh hoge' ],
+            files=[
+                { 'path': 'build/foo.sh', 'data': '#!/bin/sh\necho $1\n', 'executable': True },
+                { 'path': 'test/sample-1.in', 'data': 'foo\n' },
+                { 'path': 'test/sample-1.out', 'data': 'foo\n' },
+            ],
+            expected=[ {
+                'result': 'WA',
+                'testcase': {
+                    'name': 'sample-1',
+                    'input': '%s/test/sample-1.in',
+                    'output': '%s/test/sample-1.out',
+                },
+                'output': 'hoge\n',
+                'exitcode': 0,
+            } ],
+        )
+
+    def test_call_test_fail(self):
+        self.snippet_call_test(
+            args=[ '-c', './foo.sh' ],
+            files=[
+                { 'path': 'foo.sh', 'data': '#!/bin/sh\necho bar\nexit 3\n', 'executable': True },
+                { 'path': 'test/sample-1.in', 'data': 'foo\n' },
+                { 'path': 'test/sample-1.out', 'data': 'foo\n' },
+            ],
+            expected=[ {
+                'result': 'WA',
+                'testcase': {
+                    'name': 'sample-1',
+                    'input': '%s/test/sample-1.in',
+                    'output': '%s/test/sample-1.out',
+                },
+                'output': 'bar\n',
+                'exitcode': 3,
+            } ],
+        )
+
+    def test_call_test_dir(self):
+        self.snippet_call_test(
+            args=[ '-c', 'cat', '-d', 'p/o/../../p/o/y/o' ],
+            files=[
+                { 'path': 'p/o/y/o/sample-1.in', 'data': 'foo\n' },
+                { 'path': 'p/o/y/o/sample-1.out', 'data': 'foo\n' },
+                { 'path': 'test/sample-2.in', 'data': 'bar\n' },
+                { 'path': 'test/sample-2.out', 'data': 'bar\n' },
+            ],
+            expected=[ {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-1',
+                    'input': '%s/p/o/y/o/sample-1.in',
+                    'output': '%s/p/o/y/o/sample-1.out',
+                },
+                'output': 'foo\n',
+                'exitcode': 0,
+            } ],
+        )
+
+    def test_call_test_format(self):
+        self.snippet_call_test(
+            args=[ '-c', 'cat', '-d', 'yuki/coder', '-f', 'test_%e/%s' ],
+            files=[
+                { 'path': 'yuki/coder/test_in/sample-1.txt', 'data': 'foo\n' },
+                { 'path': 'yuki/coder/test_out/sample-1.txt', 'data': 'foo\n' },
+                { 'path': 'test_in/sample-2.in', 'data': 'bar\n' },
+                { 'path': 'test_out/sample-2.out', 'data': 'bar\n' },
+            ],
+            expected=[ {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-1.txt',
+                    'input': '%s/yuki/coder/test_in/sample-1.txt',
+                    'output': '%s/yuki/coder/test_out/sample-1.txt',
+                },
+                'output': 'foo\n',
+                'exitcode': 0,
+            } ],
+        )
+
+    def test_call_test_format_select(self):
+        self.snippet_call_test(
+            args=[ '-c', 'cat', '-d', 'yuki/coder', '-f', 'test_%e/%s', 'yuki/coder/test_in/sample-2.txt', 'yuki/coder/test_in/sample-3.txt', 'yuki/coder/test_out/sample-3.txt' ],
+            files=[
+                { 'path': 'yuki/coder/test_in/sample-1.txt', 'data': 'foo\n' },
+                { 'path': 'yuki/coder/test_out/sample-1.txt', 'data': 'foo\n' },
+                { 'path': 'yuki/coder/test_in/sample-2.txt', 'data': 'bar\n' },
+                { 'path': 'yuki/coder/test_out/sample-2.txt', 'data': 'bar\n' },
+                { 'path': 'yuki/coder/test_in/sample-3.txt', 'data': 'baz\n' },
+                { 'path': 'yuki/coder/test_out/sample-3.txt', 'data': 'baz\n' },
+            ],
+            expected=[ {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-2.txt',
+                    'input': '%s/yuki/coder/test_in/sample-2.txt',
+                },
+                'output': 'bar\n',
+                'exitcode': 0,
+            }, {
+                'result': 'AC',
+                'testcase': {
+                    'name': 'sample-3.txt',
+                    'input': '%s/yuki/coder/test_in/sample-3.txt',
+                    'output': '%s/yuki/coder/test_out/sample-3.txt',
+                },
+                'output': 'baz\n',
+                'exitcode': 0,
+            } ],
+        )