diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e942a81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +```python +>>> # short program to reproduce the bug +``` + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**System (please complete the following information):** + - OS: [e.g. Windows] + - python-codeforces version [e.g. 0.2.2] + - python version [eg: 3.7] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9e26ac2 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,24 @@ +language: python + +sudo: required +dist: xenial + +matrix: + include: + - python: 3.6 + env: TOXENV=py36 + - python: 3.7 + env: TOXENV=py37 + + - python: 3.7 + env: TOXENV=flake8 + - python: 3.7 + env: TOXENV=pylint + + allow_failures: + - env: TOXENV=flake8 + - env: TOXENV=pylint + +install: pip install tox + +script: tox diff --git a/README.md b/README.md index 31a28c9..1a46817 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,79 @@ -# python-codeforces -[![Documentation Status](https://readthedocs.org/projects/python-codeforces/badge/?version=latest)](https://python-codeforces.readthedocs.io/en/latest/?badge=latest) +

python-codeforces

-Codeforces API wrapper for python +
+ Codeforces API wrapper for python +
+ +
+ +
+ + Documentation Status + + + Build Status + + + Supported Python versions + +
+ +--- + +### Installation + +#### Using `pip` + +```shell +pip install python-codeforces +``` + +#### From source + +```shell +git clone https://github.com/Mukundan314/python-codeforces.git +cd python-codeforces +pip install . +``` + +#### For Development + +```shell +git clone https://github.com/Mukundan314/python-codeforces.git +cd python-codeforces +pip install -e . +``` + +### Commandline tools + +#### `cf-run` + +Run a program against sample testcases. + +```shell +usage: cf-run [-h] [-t TIMEOUT] [-g] contestId index program + +positional arguments: + contestId Id of the contest. It is not the round number. It can + be seen in contest URL. + index A letter or a letter followed by a digit, that + represent a problem index in a contest. + program Path to executable that needs to be tested + +optional arguments: + -h, --help show this help message and exit + -t TIMEOUT, --timeout TIMEOUT + Timeout for program in seconds, -1 for no time limit + (default: 10) + -g, --gym If true open gym contest instead of regular contest. + (default: false) +``` + +### Documentation + +Documentation can be found at https://python-codeforces.readthedocs.io/en/latest/ + +### License + +See [LICENSE](LICENSE). diff --git a/codeforces/__init__.py b/codeforces/__init__.py index 0380715..b7f93e2 100644 --- a/codeforces/__init__.py +++ b/codeforces/__init__.py @@ -1,3 +1,10 @@ -from . import error +""" +Codeforces API wrapper for python +""" from . import api +from . import cf_run +from . import error from . import problem +from . import submission + +__all__ = ['api', 'cf_run', 'error', 'problem', 'submission'] diff --git a/codeforces/api.py b/codeforces/api.py index 8254bc9..ee141c4 100644 --- a/codeforces/api.py +++ b/codeforces/api.py @@ -1,34 +1,28 @@ -""" -Functions to call the codeforces api -""" +"""Functions to call the codeforces api.""" import hashlib import json -import os -import random +import secrets import time -import urllib.error import urllib.parse -import urllib.request + +import requests from . import error __all__ = ['call'] - CODEFORCES_API_URL = "https://codeforces.com/api/" -def __generate_api_sig(method, args, secret): - rand = "%06d" % random.randint(0, 999999) +def _generate_api_sig(method, args, secret): + rand = "%06d" % secrets.randbelow(999999) url_args = urllib.parse.urlencode(sorted(args.items())) - return rand + hashlib.sha512( - ("%s/%s?%s#%s" % (rand, method, url_args, secret)).encode('utf-8') - ).hexdigest() + return rand + hashlib.sha512(("%s/%s?%s#%s" % (rand, method, url_args, secret)).encode('utf-8')).hexdigest() def call(method, key=None, secret=None, **kwargs): """ - Call a Codeforces API method + Call a Codeforces API method. Parameters ---------- @@ -46,33 +40,25 @@ def call(method, key=None, secret=None, **kwargs): ------- any A python object containing the results of the api call. + """ - args = kwargs.copy() + params = kwargs.copy() if (key is not None) and (secret is not None): - args['time'] = int(time.time()) - args['apiKey'] = key - args['apiSig'] = __generate_api_sig(method, args, secret) - - url_args = urllib.parse.urlencode(args) - url = os.path.join(CODEFORCES_API_URL, "%s?%s" % (method, url_args)) - - try: - with urllib.request.urlopen(url) as res: - data = json.loads(res.read().decode('utf-8')) - except urllib.error.HTTPError as err: - if err.code == 400: - data = json.loads(err.read()) - elif err.code == 404: - data = { - 'status': 'FAILED', - 'comment': "%s: No such method" % method - } - elif err.code in (429, 503): + params['time'] = int(time.time()) + params['apiKey'] = key + params['apiSig'] = _generate_api_sig(method, params, secret) + + url = urllib.parse.urljoin(CODEFORCES_API_URL, "%s" % method) + + with requests.get(url, params=params) as res: + if res.status_code == 404: + data = {'status': 'FAILED', 'comment': "%s: No such method" % method} + elif res.status_code in (429, 503): time.sleep(1) return call(method, key, secret, **kwargs) else: - raise + data = json.loads(res.text) if data['status'] == 'FAILED': raise error.CodeforcesAPIError(data['comment'], method, kwargs) diff --git a/codeforces/cf_run.py b/codeforces/cf_run.py new file mode 100644 index 0000000..19d329b --- /dev/null +++ b/codeforces/cf_run.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python + +import argparse +import shlex +import subprocess +import tempfile +import time + +import colorama + +from . import problem + +colorama.init(autoreset=True) + + +def main(argv=None): + parser = argparse.ArgumentParser() + + parser.add_argument( + 'contestId', type=int, help="Id of the contest. It is not the round number. It can be seen in contest URL.") + + parser.add_argument( + 'index', + type=str, + help="A letter or a letter followed by a digit, that represent a problem index in a contest.") + + parser.add_argument('program', type=str, help="Path to executable that needs to be tested") + + parser.add_argument( + '-t', + '--timeout', + type=int, + default=10, + help="Timeout for program in seconds, -1 for no time limit (default: 10)") + + parser.add_argument( + '-g', + '--gym', + action='store_true', + help="If true open gym contest instead of regular contest. (default: false)") + + if argv: + args = parser.parse_args(argv) + else: + args = parser.parse_args() + + args.timeout = None if args.timeout == -1 else args.timeout + + title, time_limit, memory_limit, sample_tests = problem.get_info(args.contestId, args.index, gym=args.gym) + + print(title) + print("time limit per test:", time_limit) + print("memory limit per test:", memory_limit) + + print() + + for inp, ans in sample_tests: + tmp = tempfile.TemporaryFile('w') + tmp.write(inp) + tmp.seek(0) + + start = time.time() + + proc = subprocess.run( + shlex.split(args.program), + stdin=tmp, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=args.timeout, + universal_newlines=True) + + stdout = proc.stdout + stderr = proc.stderr + + time_used = time.time() - start + + print('-' * 80, '\n') + + print('Time: %d ms\n' % (time_used * 1000)) + + print(colorama.Style.BRIGHT + 'Input') + print(inp) + + print(colorama.Style.BRIGHT + "Participant's output") + print(stdout) + + if stderr: + print(colorama.Style.BRIGHT + "stderr") + print(stderr) + + print(colorama.Style.BRIGHT + "Jury's answer") + print(ans) diff --git a/codeforces/error.py b/codeforces/error.py index 0740271..18e3f0e 100644 --- a/codeforces/error.py +++ b/codeforces/error.py @@ -1,6 +1,11 @@ +"""All exceptions used in codeforces.""" + __all__ = ['CodeforcesAPIError'] + class CodeforcesAPIError(Exception): + """Raised by api.call if method returns a error or is not found.""" + def __init__(self, comment, method, args): self.comment = comment self.method = method diff --git a/codeforces/problem.py b/codeforces/problem.py index d3fc9cf..e9ca2b7 100644 --- a/codeforces/problem.py +++ b/codeforces/problem.py @@ -1,6 +1,7 @@ -import os -import urllib.request -import urllib.error +"""Functions to info about a problem.""" +import urllib.parse + +import requests from bs4 import BeautifulSoup __all__ = ['get_info'] @@ -8,7 +9,7 @@ CODEFORCES_URL = "https://codeforces.com" -def get_info(contest_id, index, lang='en'): +def get_info(contest_id, index, gym=False, lang='en'): """ Get info for a contest problem. @@ -21,6 +22,9 @@ def get_info(contest_id, index, lang='en'): Usually a letter of a letter, followed by a digit, that represent a problem index in a contest. It can be seen in problem URL. For example: /contest/566/**A** + gym: bool + If true gym problem is returned otherwise regular problem is + returned. Returns ------- @@ -32,24 +36,25 @@ def get_info(contest_id, index, lang='en'): Memory limit specification for the problem. sample_tests: zip Sample tests given for the problem. - """ - problem_url = os.path.join( - CODEFORCES_URL, - "contest/%d/problem/%s?lang=%s" % (contest_id, index, lang) - ) + """ + if gym: + problem_url = urllib.parse.urljoin(CODEFORCES_URL, "gym/%d/problem/%s" % (contest_id, index)) + else: + problem_url = urllib.parse.urljoin(CODEFORCES_URL, "contest/%d/problem/%s" % (contest_id, index)) - with urllib.request.urlopen(problem_url) as res: - soup = BeautifulSoup(res.read(), 'html.parser') + with requests.get(problem_url, params={'lang': lang}) as res: + soup = BeautifulSoup(res.text, 'html.parser') title = soup.find_all("div", class_="title")[0].text time_limit = soup.find_all("div", class_="time-limit")[0].text[19:] memory_limit = soup.find_all("div", class_="memory-limit")[0].text[21:] - i = [i.pre.string[1:] for i in soup.find_all("div", class_="input")] - o = [i.pre.string[1:] for i in soup.find_all("div", class_="output")] + inputs = [i.pre.get_text('\n').lstrip('\n') for i in soup.find_all("div", class_="input")] + + outputs = [i.pre.get_text('\n').lstrip('\n') for i in soup.find_all("div", class_="output")] - sample_tests = zip(i, o) + sample_tests = zip(inputs, outputs) return (title, time_limit, memory_limit, sample_tests) diff --git a/codeforces/submission.py b/codeforces/submission.py new file mode 100644 index 0000000..6969234 --- /dev/null +++ b/codeforces/submission.py @@ -0,0 +1,35 @@ +import json + +import requests +from bs4 import BeautifulSoup + + +def get_submission(submission_id): + """Get source code associated with a submission_id + + Parameters + ---------- + submission_id: int + Id of the submission. + For example: /contest/1113/submission/**50046519** + + Returns + ------- + source code of the submission + """ + with requests.Session() as sess: + sess.headers.update({'User-Agent': 'python-codeforces/0.1.0'}) + + res = sess.get('https://codeforces.com') + soup = BeautifulSoup(res.text, 'html.parser') + + csrf_token = soup.find('meta', attrs={'name': 'X-Csrf-Token'})['content'] + + res = sess.post( + 'https://codeforces.com/data/submitSource', { + 'submissionId': submission_id, + 'csrf_token': csrf_token + }, + headers={'X-Csrf-Token': csrf_token}) + + return json.loads(res.text)['source'] diff --git a/docs/conf.py b/docs/conf.py index 32bdcec..055ed37 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = '0.2' # The full version, including alpha/beta/rc tags -release = '0.2.1' +release = '0.2.7' # -- General configuration --------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index c1f5f71..da3c8bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ beautifulsoup4 +colorama +requests diff --git a/setup.py b/setup.py index 2205ede..9ce4540 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,31 @@ -from setuptools import setup, find_packages - +from setuptools import find_packages, setup with open('README.md') as f: long_description = f.read() setup( name='python-codeforces', - version='0.2.1', - author='Mukundan', - author_email='mukundan314@gmail.com', + version='0.2.7', description='Codeforces API wrapper for python', long_description=long_description, long_description_content_type='text/markdown', - license='MIT', + classifiers=[ + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7' + ], keywords='codeforces', url='https://github.com/Mukundan314/python-codeforces', - python_requires='>=3.6,<4', - install_requires=['bs4'], - extras_requires={ 'docs': ['sphinx', 'sphinx_rtd_theme'] }, + author='Mukundan', + author_email='mukundan314@gmail.com', + license='MIT', packages=find_packages(exclude=['docs']), - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7' - ] -) + install_requires=['beautifulsoup4', 'colorama', 'requests'], + extras_requires={'docs': ['sphinx', 'sphinx_rtd_theme']}, + entry_points={'console_scripts': ['cf-run = codeforces.cf_run:main']}, + python_requires='>=3.6,<4', + project_urls={ + "Bug Tracker": "https://github.com/Mukundan314/python-codeforces/issues/", + "Documentation": "https://python-codeforces.readthedocs.io/en/stable/", + "Source": "https://github.com/Mukundan314/python-codeforces" + }) diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 0000000..fd89c1d --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,29 @@ +import codeforces +import pytest + + +class TestCall(object): + def test_undefined_method(self): + with pytest.raises(codeforces.error.CodeforcesAPIError): + method = "undefined.method" + codeforces.api.call(method) + + def test_unauth_method(self): + method = "user.info" + handles = "python-codeforces" + + codeforces.api.call(method, handles=handles) + + key = "0d905168ea10217dd91472e861bf8c80932f060e" + secret = "3d3872085b0255159381a1884e9f66d5213ba796" + codeforces.api.call(method, key=key, secret=secret, handles=handles) + + def test_auth_method(self): + method = "user.friends" + + key = "0d905168ea10217dd91472e861bf8c80932f060e" + secret = "3d3872085b0255159381a1884e9f66d5213ba796" + codeforces.api.call(method, key=key, secret=secret) + + with pytest.raises(codeforces.error.CodeforcesAPIError): + codeforces.api.call(method) diff --git a/test/test_problem.py b/test/test_problem.py new file mode 100644 index 0000000..bcf615f --- /dev/null +++ b/test/test_problem.py @@ -0,0 +1,15 @@ +import codeforces +import pytest + + +class TestGetInfo(object): + def test_normal_problem(self): + contest_id = 1 + index = 'A' + + title, time_limit, memory_limit, sample_tests = codeforces.problem.get_info(contest_id, index) + + assert (title == 'A. Theatre Square') + assert (time_limit == '1 second') + assert (memory_limit == '256 megabytes') + assert (list(sample_tests) == [('6 6 4', '4')]) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..59a554a --- /dev/null +++ b/tox.ini @@ -0,0 +1,35 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py36, py37, flake8, pylint +skip_missing_interpreters = true + +[testenv] +deps = + pytest +commands = + pytest + +[testenv:flake8] +basepython = python +skip_install = true +deps = + flake8 + flake8-docstrings>=0.2.7 + flake8-import-order>=0.9 + pep8-naming + flake8-colors +commands = + flake8 {toxinidir}/codeforces/ {toxinidir}/test/ {toxinidir}/setup.py + +[testenv:pylint] +basepython = python +skip_install = true +deps = + pylint + -r{toxinidir}/requirements.txt +commands = + pylint {toxinidir}/codeforces/