Repository: online-judge-tools/api-client

Issue:
CodeChef parser is broken
``` console
$ oj -v d -n 'https://www.codechef.com/PLIN2020/problems/CNTSET'              
[DEBUG] args: Namespace(cookie=PosixPath('/home/ubuntu/.local/share/online-judge-tools/cookie.jar'), directory=None, dry_run=True, format=None, json=False, silent=False, subcommand='d', system=False, url='https://www.codechef.com/PLIN2020/problems/CNTSET', verbose=True, version=False, yukicoder_token=None)
[DEBUG] User-Agent: online-judge-tools/10.0.3 (+https://github.com/online-judge-tools/oj)
[DEBUG] 
Traceback (most recent call last):
  File "/home/ubuntu/.local/lib/python3.6/site-packages/onlinejudge_command/main.py", line 227, in main
    run_program(namespace, parser=parser)
  File "/home/ubuntu/.local/lib/python3.6/site-packages/onlinejudge_command/main.py", line 202, in run_program
    download(args)
  File "/home/ubuntu/.local/lib/python3.6/site-packages/onlinejudge_command/subcommand/download.py", line 47, in download
    samples = problem.download_sample_cases(session=sess)
  File "/home/ubuntu/.local/lib/python3.6/site-packages/onlinejudge_workaround_for_conflict/service/codechef.py", line 59, in download_sample_cases
    raise SampleParseError('no markdown')
onlinejudge_workaround_for_conflict.type.SampleParseError: no markdown

[ERROR] no markdown
$ oj --version
online-judge-tools 10.0.3 (+ online-judge-api-client 10.0.8)
```

Discussion:

Patch:
diff --git a/README.md b/README.md
index 78b5e52..e1c91fd 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ $ pip3 install online-judge-api-client
 | [Anarchy Golf](http://golf.shinh.org/)                                         | :heavy_check_mark: | :grey_question: (same to samples) |                    |                    |                    |                    |
 | [AtCoder](https://atcoder.jp/)                                                 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
 | [AtCoder Problems](https://kenkoooo.com/atcoder) (virtual contests)            |                    |                    |                    | :heavy_check_mark: |                    |                    |
-| [CodeChef](https://www.codechef.com/)                                          | :x: [issue](https://github.com/online-judge-tools/api-client/issues/49) |                    |                    |                    |                    |                    |
+| [CodeChef](https://www.codechef.com/)                                          | :heavy_check_mark: |                    | :heavy_check_mark: | :heavy_check_mark: | :grey_question:    |                    |
 | [Codeforces](https://codeforces.com/)                                          | :heavy_check_mark: |                    | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :x: [issue](https://github.com/online-judge-tools/api-client/issues/127) |
 | [CS Academy](https://csacademy.com/)                                           | :heavy_check_mark: |                    |                    |                    |                    |                    |
 | [Facebook Hacker Cup](https://www.facebook.com/hackercup/)                     | :heavy_check_mark: |                    |                    |                    |                    |                    |
diff --git a/onlinejudge/service/codechef.py b/onlinejudge/service/codechef.py
index 57ed608..9083bfd 100644
--- a/onlinejudge/service/codechef.py
+++ b/onlinejudge/service/codechef.py
@@ -3,17 +3,22 @@
 the module for CodeChef (https://www.codechef.com/)
 """
 
+import json
 import re
 import urllib.parse
+from logging import getLogger
 from typing import *
 
 import requests
 
 import onlinejudge._implementation.testcase_zipper
+import onlinejudge._implementation.utils as utils
 import onlinejudge.dispatch
 import onlinejudge.type
 from onlinejudge.type import SampleParseError
 
+logger = getLogger(__name__)
+
 
 class CodeChefService(onlinejudge.type.Service):
     def get_url(self) -> str:
@@ -31,14 +36,131 @@ class CodeChefService(onlinejudge.type.Service):
             return cls()
         return None
 
+    def get_url_of_login_page(self) -> str:
+        return 'https://www.codechef.com/'
+
+    def is_logged_in(self, *, session: Optional[requests.Session] = None) -> bool:
+        session = session or utils.get_default_session()
+        url = 'https://www.codechef.com/certificates/'
+        resp = utils.request('GET', url, session=session, raise_for_status=False)
+        return resp.status_code == 200
+
+
+class CodeChefProblemData(onlinejudge.type.ProblemData):
+    def __init__(self, *, contest_id: str, data: Dict[str, Any]):
+        self.contest_id = contest_id
+        self.data = data
+
+    @property
+    def json(self) -> bytes:
+        return json.dumps(self.data).encode()
+
+    @property
+    def problem(self) -> 'CodeChefProblem':
+        return CodeChefProblem(contest_id=self.contest_id, problem_id=self.data['code'])
+
+    @property
+    def name(self) -> str:
+        return self.data['name']
+
+    # TODO: Support problems with old formats. Our old parser may help it: https://github.com/online-judge-tools/api-client/pull/50/commits/a6c2c0808bc2b5ef5c81985877522b8e8ea92bd1
+    @property
+    def sample_cases(self) -> Optional[List[onlinejudge.type.TestCase]]:
+        if 'problemComponents' not in self.data:
+            return None
+        testcases: List[onlinejudge.type.TestCase] = []
+        for testcase in self.data['problemComponents']['sampleTestCases']:
+            testcases.append(onlinejudge.type.TestCase(
+                name='sample-{}'.format(testcase['id']),
+                input_name='input',
+                input_data=utils.textfile(testcase['input']).encode(),
+                output_name='output',
+                output_data=utils.textfile(testcase['output']).encode(),
+            ))
+        return testcases
+
+
+class CodeChefContestData(onlinejudge.type.ContestData):
+    def __init__(self, *, data: Dict[str, Any]):
+        self.data = data
+
+    @property
+    def json(self) -> bytes:
+        return json.dumps(self.data).encode()
+
+    @property
+    def contest(self) -> 'CodeChefContest':
+        return CodeChefContest(contest_id=self.data['code'])
+
+    @property
+    def name(self) -> str:
+        return self.data['name']
+
+    def get_problem_data(self) -> List['CodeChefProblemData']:
+        return [CodeChefProblemData(contest_id=self.data['code'], data=data) for data in self.data['problems'].values()]
+
+
+class CodeChefContest(onlinejudge.type.Contest):
+    def __init__(self, *, contest_id: str):
+        self.contest_id = contest_id
+
+    def get_url(self) -> str:
+        return 'https://www.codechef.com/{}'.format(self.contest_id)
+
+    def get_service(self) -> CodeChefService:
+        return CodeChefService()
+
+    @classmethod
+    def from_url(cls, url: str) -> Optional['CodeChefContest']:
+        # example: https://www.codechef.com/JAN20A
+        result = urllib.parse.urlparse(url)
+        if result.scheme in ('', 'http', 'https') \
+                and result.netloc == 'www.codechef.com':
+            m = re.match(r'/([0-9A-Z_a-z-]+)', result.path)
+            if m:
+                contest_id = m.group(1)
+                return cls(contest_id=contest_id)
+        return None
+
+    def list_problems(self, *, session: Optional[requests.Session] = None) -> Sequence['CodeChefProblem']:
+        return [problem_data.problem for problem_data in self.download_data(session=session).get_problem_data()]
+
+    def download_data(self, *, session: Optional[requests.Session] = None) -> CodeChefContestData:
+        session = session or utils.get_default_session()
+
+        # get
+        url = 'https://www.codechef.com/api/contests/{}'.format(self.contest_id)
+        resp = utils.request('GET', url, session=session)
+        data = json.loads(resp.content)
+        if data['status'] != 'success':
+            logger.debug('json: %s', resp.content.decode())
+            raise SampleParseError('CodeChef API failed with: {}'.format(data.get('message')))
+
+        return CodeChefContestData(data=data)
+
 
 class CodeChefProblem(onlinejudge.type.Problem):
     def __init__(self, *, contest_id: str, problem_id: str):
         self.contest_id = contest_id
         self.problem_id = problem_id
 
+    def download_data(self, *, session: Optional[requests.Session] = None) -> CodeChefProblemData:
+        session = session or utils.get_default_session()
+
+        # get
+        url = 'https://www.codechef.com/api/contests/{}/problems/{}'.format(self.contest_id, self.problem_id)
+        resp = utils.request('GET', url, session=session)
+        data = json.loads(resp.content)
+        if data['status'] != 'success':
+            logger.debug('json: %s', resp.content.decode())
+            raise SampleParseError('CodeChef API failed with: {}'.format(data.get('message')))
+
+        return CodeChefProblemData(contest_id=self.contest_id, data=data)
+
     def download_sample_cases(self, *, session: Optional[requests.Session] = None) -> List[onlinejudge.type.TestCase]:
-        raise SampleParseError("removed. see https://github.com/online-judge-tools/api-client/issues/49")
+        sample_cases = self.download_data(session=session).sample_cases
+        assert sample_cases is not None
+        return sample_cases
 
     def get_url(self, *, contests: bool = True) -> str:
         return 'https://www.codechef.com/{}/problems/{}'.format(self.contest_id, self.problem_id)
@@ -46,6 +168,9 @@ class CodeChefProblem(onlinejudge.type.Problem):
     def get_service(self) -> CodeChefService:
         return CodeChefService()
 
+    def get_contest(self) -> CodeChefContest:
+        return CodeChefContest(contest_id=self.contest_id)
+
     @classmethod
     def from_url(cls, url: str) -> Optional['CodeChefProblem']:
         # example: https://www.codechef.com/JAN20A/problems/DYNAMO
@@ -63,4 +188,5 @@ class CodeChefProblem(onlinejudge.type.Problem):
 
 
 onlinejudge.dispatch.services += [CodeChefService]
+onlinejudge.dispatch.contests += [CodeChefContest]
 onlinejudge.dispatch.problems += [CodeChefProblem]


Test patch:
diff --git a/onlinejudge_api/get_contest.py b/onlinejudge_api/get_contest.py
index 4611dec..2c090fd 100644
--- a/onlinejudge_api/get_contest.py
+++ b/onlinejudge_api/get_contest.py
@@ -1,7 +1,9 @@
+import json
 from typing import *
 
 from onlinejudge.service.atcoder import AtCoderContest
 from onlinejudge.service.atcoder_problems import AtCoderProblemsContest
+from onlinejudge.service.codechef import CodeChefContest
 from onlinejudge.service.codeforces import CodeforcesContest
 from onlinejudge.service.yukicoder import YukicoderContest
 from onlinejudge.type import *
@@ -152,6 +154,25 @@ def main(contest: Contest, *, is_full: bool, session: requests.Session) -> Dict[
             assert 'raw' in result
             del result["raw"]
 
+    elif isinstance(contest, CodeChefContest):
+        data = contest.download_data(session=session)
+        result["url"] = data.url
+        result["name"] = data.name
+        for problem_data in data.get_problem_data():
+            data_ = {
+                "url": problem_data.url,
+                "name": problem_data.name,
+                "context": {
+                    "contest": {
+                        "name": data.name,
+                        "url": contest.get_url(),
+                    },
+                },
+            }
+            result["problems"].append(data_)
+        if is_full:
+            result["raw"] = json.loads(data.json)
+
     else:
         assert False
 
diff --git a/tests/get_contest_codechef.py b/tests/get_contest_codechef.py
new file mode 100644
index 0000000..d0983b5
--- /dev/null
+++ b/tests/get_contest_codechef.py
@@ -0,0 +1,109 @@
+import unittest
+
+from onlinejudge_api.main import main
+
+
+class GetContestCodeChefTest(unittest.TestCase):
+    def test_cook131b(self):
+        url = "https://www.codechef.com/COOK131B"
+        expected = {
+            "status": "ok",
+            "messages": [],
+            "result": {
+                "url": "https://www.codechef.com/COOK131B",
+                "problems": [{
+                    "url": "https://www.codechef.com/COOK131B/problems/SHOEFIT",
+                    "name": "Shoe Fit",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/CHFGCD",
+                    "name": "Chef and GCD",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/XORORED",
+                    "name": "XOR-ORED",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/CHFPLN",
+                    "name": "Chef In Infinite Plane",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/MODEQUAL",
+                    "name": "Mod Equality",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/BEAUSUB",
+                    "name": "Beautiful Subsequence",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/COLRGRPH",
+                    "name": "Hidden Colored Graph",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/MATBEAUT",
+                    "name": "Make the Matrix Beautiful",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/SPTREE2",
+                    "name": "A Special Tree 2",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }, {
+                    "url": "https://www.codechef.com/COOK131B/problems/GCDLEN",
+                    "name": "Maximal GCD",
+                    "context": {
+                        "contest": {
+                            "name": "July Cook-Off 2021 Division 2",
+                            "url": "https://www.codechef.com/COOK131B"
+                        }
+                    }
+                }],
+                "name": "July Cook-Off 2021 Division 2"
+            },
+        }
+        actual = main(['get-contest', url], debug=True)
+        self.assertEqual(expected, actual)
diff --git a/tests/get_problem_codechef.py b/tests/get_problem_codechef.py
new file mode 100644
index 0000000..11a60b5
--- /dev/null
+++ b/tests/get_problem_codechef.py
@@ -0,0 +1,22 @@
+import unittest
+
+from onlinejudge_api.main import main
+
+
+class GetProblemCodeChefTest(unittest.TestCase):
+    def test_xorored(self):
+        url = 'https://www.codechef.com/COOK131B/problems/XORORED'
+        expected = {
+            "status": "ok",
+            "messages": [],
+            "result": {
+                "url": "https://www.codechef.com/COOK131B/problems/XORORED",
+                "tests": [{
+                    "input": "1\n2\n4 6\n",
+                    "output": "6 2\n"
+                }],
+                "context": {}
+            },
+        }
+        actual = main(['get-problem', url], debug=True)
+        self.assertEqual(expected, actual)
diff --git a/tests/service_codechef.py b/tests/service_codechef.py
index 3635e36..c65f08d 100644
--- a/tests/service_codechef.py
+++ b/tests/service_codechef.py
@@ -1,7 +1,7 @@
 import unittest
 
 from onlinejudge.service.codechef import CodeChefProblem, CodeChefService
-from onlinejudge.type import SampleParseError, TestCase
+from onlinejudge.type import TestCase
 
 
 class CodeChefSerivceTest(unittest.TestCase):
@@ -15,15 +15,36 @@ class CodeChefProblemTest(unittest.TestCase):
         self.assertEqual(CodeChefProblem.from_url('https://www.codechef.com/COOK113A/problems/DAND').contest_id, 'COOK113A')
         self.assertEqual(CodeChefProblem.from_url('https://www.codechef.com/COOK113A/problems/DAND').problem_id, 'DAND')
 
-    @unittest.skip("the parser is broken now. see https://github.com/online-judge-tools/api-client/issues/49")
-    def test_download_samples(self):
-        self.assertEqual(CodeChefProblem.from_url('https://www.codechef.com/COOK113A/problems/DAND').download_sample_cases(), [
-            TestCase(name='sample', input_name='Example Input', input_data=b'6\n1 9 3\n4 7 1\n10 75 12\n3 8 3\n5 10 2\n192 913893 3812\n', output_name='Example Output', output_data=b'4\n7\n64\n4\n8\n909312\n'),
-        ])
-        self.assertEqual(CodeChefProblem.from_url('https://www.codechef.com/PLIN2020/problems/CNTSET').download_sample_cases(), [
-            TestCase(name='sample', input_name='Sample Input', input_data=b'4 2\n', output_name='Sample Output', output_data=b'12\n'),
-        ])
-
-    @unittest.skip("the parser is broken now. see https://github.com/online-judge-tools/api-client/issues/49")
-    def test_download_samples_todo(self):
-        self.assertRaises(SampleParseError, lambda: CodeChefProblem.from_url('https://www.codechef.com/CNES2017/problems/ACESQN').download_sample_cases())
+    def test_download_samples_chfgcd(self):
+        url = 'https://www.codechef.com/COOK131B/problems/CHFGCD'
+        expected = [
+            TestCase(name='sample-1', input_name='input', input_data=b'2\n4 16\n4 55\n', output_name='output', output_data=b'0\n1\n'),
+        ]
+        self.assertEqual(CodeChefProblem.from_url(url).download_sample_cases(), expected)
+
+    # TODO: support problems with an old format
+    @unittest.expectedFailure
+    def test_download_samples_dand(self):
+        url = 'https://www.codechef.com/COOK113A/problems/DAND'
+        expected = [
+            TestCase(name='sample-1', input_name='input', input_data=b'6\n1 9 3\n4 7 1\n10 75 12\n3 8 3\n5 10 2\n192 913893 3812\n', output_name='output', output_data=b'4\n7\n64\n4\n8\n909312\n'),
+        ]
+        self.assertEqual(CodeChefProblem.from_url(url).download_sample_cases(), expected)
+
+    # TODO: support problems with an old format
+    @unittest.expectedFailure
+    def test_download_samples_cntset(self):
+        url = 'https://www.codechef.com/PLIN2020/problems/CNTSET'
+        expected = [
+            TestCase(name='sample-1', input_name='input', input_data=b'4 2\n', output_name='output', output_data=b'12\n'),
+        ]
+        self.assertEqual(CodeChefProblem.from_url(url).download_sample_cases(), expected)
+
+    # TODO: support problems with an old format
+    @unittest.expectedFailure
+    def test_download_samples_acesqn(self):
+        url = 'https://www.codechef.com/CNES2017/problems/ACESQN'
+        expected = [
+            TestCase(name='sample-1', input_name='input', input_data=b'1\n5\n1 2 3 4 5\n', output_name='output', output_data=b'2\n'),
+        ]
+        self.assertEqual(CodeChefProblem.from_url(url).download_sample_cases(), expected)