diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 09fcc54..34c6a9b 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -7,12 +7,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.9 + - name: Set up Python uses: actions/setup-python@v1 with: - python-version: 3.9 + python-version: 3.12 + - name: Install requirements + run: pip install -r requirements.txt - name: Build a binary wheel and a source tarball - run: python setup.py sdist + run: python setup.py sdist bdist_wheel - name: Publish distribution package to Test PyPI uses: pypa/gh-action-pypi-publish@master with: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 477d11b..1a161de 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - - name: Set up Python 3.9 + - name: Set up Python uses: actions/setup-python@v1 with: - python-version: 3.9 + python-version: 3.12 - name: Install requirements run: pip install -r requirements.txt - name: Install test requirements @@ -23,3 +23,4 @@ jobs: run: pytest -vvvv -s env: LEETCODE_SESSION_ID: ${{ secrets.LEETCODE_SESSION }} + LEETCODE_CSRF_TOKEN: ${{ secrets.LEETCODE_CSRF_TOKEN}} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a14094 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Test docker file +# `docker build .` verifies that the project builds and runs pytest +FROM python:3.12-slim + +# Verify wheel can be built +RUN pip install setuptools twine wheel + +WORKDIR /app + +COPY . /app/ + +RUN pip install --no-cache-dir . + +RUN python setup.py sdist bdist_wheel + +RUN python -c "import sys, leetcode; print(f'Package installed successfully in Python {sys.version}'); leetcode.DefaultApi(leetcode.ApiClient(leetcode.Configuration())); print('leetcode package is installed and functional')" + +RUN pip install pytest +RUN pytest -vvvv -s diff --git a/README.md b/README.md index caaa3b7..0ed37ea 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -![build](https://github.com/prius/python-leetcode/actions/workflows/publish-to-pypi.yml/badge.svg) -![tests](https://github.com/prius/python-leetcode/actions/workflows/pytest.yml/badge.svg) +![build](https://github.com/fspv/python-leetcode/actions/workflows/publish-to-pypi.yml/badge.svg) +![tests](https://github.com/fspv/python-leetcode/actions/workflows/pytest.yml/badge.svg) ![pypi](https://badge.fury.io/py/python-leetcode.svg) ![pypi-downloads](https://img.shields.io/pypi/dm/python-leetcode) ![python-versions](https://img.shields.io/pypi/pyversions/python-leetcode) @@ -9,7 +9,7 @@ This repo contains a python client to access all known so far methods of Leetcode API. -The code is autogenerated by swagger. Swagger reference can be found here: [https://github.com/prius/leetcode-swagger](https://github.com/prius/leetcode-swagger) +The code is autogenerated by swagger. Swagger reference can be found here: [https://github.com/fspv/leetcode-swagger](https://github.com/fspv/leetcode-swagger) PyPi package link: [https://pypi.org/project/python-leetcode/](https://pypi.org/project/python-leetcode/) @@ -22,7 +22,7 @@ virtualenv -p python3 leetcode pip3 install python-leetcode ``` -Then in python shell initialize the client (if you're using chrome, cookies can be found here [chrome://settings/cookies/detail?site=leetcode.com](chrome://settings/cookies/detail?site=leetcode.com)) +Then in python shell initialize necessary environment variables. You can get it directly from your browser cookies (csrftoken and LEETCODE_SESSION) ```python import leetcode @@ -30,10 +30,6 @@ import leetcode leetcode_session = "yyy" csrf_token = "xxx" -# Experimental: Or CSRF token can be obtained automatically -import leetcode.auth -csrf_token = leetcode.auth.get_csrf_cookie(leetcode_session) - configuration = leetcode.Configuration() configuration.api_key["x-csrftoken"] = csrf_token @@ -168,10 +164,39 @@ In this case memoization topic is one of the targets for improvement, so I can g ## Example services using this library -* Anki cards generator [https://github.com/prius/leetcode-anki](https://github.com/prius/leetcode-anki) -* Leetcode helper website [https://github.com/prius/grind-helper](https://github.com/prius/grind-helper) +* Anki cards generator [https://github.com/fspv/leetcode-anki](https://github.com/fspv/leetcode-anki) +* Leetcode helper website [https://github.com/fspv/grind-helper](https://github.com/fspv/grind-helper) ## Additional info You can find other examples of usage in `example.py` Autogenerated by swagger documentation can be found [here](/README.generated.md). + +## Development + +Build package locally and upload to test pypi +```sh +virtualenv .venv +. .venv/bin/activate +pip install setuptools twine wheel +rm -rf build/ dist/ *.egg-info/ *.egg +python setup.py sdist bdist_wheel +twine upload --repository-url https://test.pypi.org/legacy/ dist/*.whl +``` + +To run tests set up env variables and run this + +```sh +docker build --env LEETCODE_SESSION_ID=$LEETCODE_SESSION_ID --env LEETCODE_CSRF_TOKEN=$LEETCODE_CSRF_TOKEN . +``` + +Test new created package before publishing +```sh +pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ python-leetcode==1.2.5 +``` + +Publish package +```sh +git tag 1.2.4 +git push --tags +``` diff --git a/example.py b/example.py index 8d38d7d..1fc29cb 100644 --- a/example.py +++ b/example.py @@ -1,11 +1,9 @@ from __future__ import annotations import os -import sys from time import sleep import leetcode -import leetcode.auth # Initialize client configuration = leetcode.Configuration() @@ -14,7 +12,7 @@ # fields which contain corresponding cookies from web browser leetcode_session: str = os.environ["LEETCODE_SESSION_ID"] -csrf_token: str = leetcode.auth.get_csrf_cookie(leetcode_session) +csrf_token = os.environ["LEETCODE_CSRF_TOKEN"] configuration.api_key["x-csrftoken"] = csrf_token configuration.api_key["csrftoken"] = csrf_token @@ -96,7 +94,7 @@ } } """, - variables=leetcode.GraphqlQueryVariables(title_slug="two-sum"), + variables=leetcode.GraphqlQueryGetQuestionDetailVariables(title_slug="two-sum"), operation_name="getQuestionDetail", ) diff --git a/git_push.sh b/git_push.sh index 1410312..e31c23b 100644 --- a/git_push.sh +++ b/git_push.sh @@ -8,7 +8,7 @@ git_repo_id=$2 release_note=$3 if [ "$git_user_id" = "" ]; then - git_user_id="prius" + git_user_id="fspv" echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" fi diff --git a/leetcode/auth.py b/leetcode/auth.py deleted file mode 100644 index d9efd68..0000000 --- a/leetcode/auth.py +++ /dev/null @@ -1,12 +0,0 @@ -import requests - - -def get_csrf_cookie(session_id: str) -> str: - response = requests.get( - "https://leetcode.com/", - cookies={ - "LEETCODE_SESSION": session_id, - }, - ) - - return response.cookies["csrftoken"] diff --git a/setup.py b/setup.py index 1323596..55b7541 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ from setuptools import find_packages, setup # noqa: H301 NAME = "python-leetcode" -VERSION = "1.2.1" +VERSION = "1.2.5" with open("README.md") as readme: DESCRIPTION: str = readme.read() @@ -26,17 +26,22 @@ # prerequisite: setuptools # http://pypi.python.org/pypi/setuptools -REQUIRES = ["urllib3 >= 1.15", "six >= 1.10", "certifi", "python-dateutil", "requests"] - setup( name=NAME, version=VERSION, description="Leetcode API", author="Pavel Safronov", author_email="pv.safronov@gmail.com", - url="https://github.com/prius/python-leetcode", + url="https://github.com/fspv/python-leetcode", keywords=["leetcode", "faang", "interview", "api"], - install_requires=REQUIRES, + install_requires=[ + "certifi >= 14.05.14", + "six >= 1.10", + "python_dateutil >= 2.5.3", + "setuptools >= 21.0.0", + "urllib3 >= 1.15.1", + "requests", + ], packages=find_packages(), include_package_data=True, long_description=DESCRIPTION, @@ -49,6 +54,9 @@ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], python_requires=">=3.8", ) diff --git a/test/base.py b/test/base.py index dca44f6..4e7689f 100644 --- a/test/base.py +++ b/test/base.py @@ -1,11 +1,47 @@ import os -from typing import Optional +from typing import Any, Optional import leetcode.api.default_api import leetcode.api_client -import leetcode.auth import leetcode.configuration +def validate_stat_status_pair(stat_status_pair: Any) -> None: + assert isinstance(stat_status_pair, leetcode.StatStatusPair) + assert isinstance(stat_status_pair.difficulty, leetcode.Difficulty) + assert isinstance(stat_status_pair.difficulty.level, int) + assert isinstance(stat_status_pair.frequency, float) + assert isinstance(stat_status_pair.is_favor, bool) + assert isinstance(stat_status_pair.paid_only, bool) + assert isinstance(stat_status_pair.stat, leetcode.Stat) + if stat_status_pair.status is not None: + assert isinstance(stat_status_pair.status, str) + + assert isinstance(stat_status_pair.stat.frontend_question_id, int) + assert isinstance(stat_status_pair.stat.is_new_question, bool) + if stat_status_pair.stat.question__article__live is not None: + assert isinstance(stat_status_pair.stat.question__article__live, bool) + if stat_status_pair.stat.question__article__slug is not None: + assert isinstance(stat_status_pair.stat.question__article__slug, str) + assert isinstance(stat_status_pair.stat.question__hide, bool) + assert isinstance(stat_status_pair.stat.question__title, str) + assert isinstance(stat_status_pair.stat.question__title_slug, str) + assert isinstance(stat_status_pair.stat.question_id, int) + assert isinstance(stat_status_pair.stat.total_acs, int) + assert isinstance(stat_status_pair.stat.total_submitted, int) + +def validate_problems(problems: Any, category_slug: str) -> None: + assert isinstance(problems, leetcode.Problems) + assert isinstance(problems.ac_easy, int) + assert isinstance(problems.ac_hard, int) + assert isinstance(problems.ac_medium, int) + assert problems.category_slug == category_slug + assert isinstance(problems.num_solved, int) + assert isinstance(problems.num_total, int) + assert isinstance(problems.stat_status_pairs, list) + + for stat_status_pair in problems.stat_status_pairs: + validate_stat_status_pair(stat_status_pair) + class BaseTest: _api_instance_containter: Optional[leetcode.api.default_api.DefaultApi] = None @@ -25,9 +61,9 @@ def _api_instance( ) -> None: self._api_instance_containter = value - def setup(self) -> None: + def setup_method(self) -> None: session_id = os.environ["LEETCODE_SESSION_ID"] - csrftoken = leetcode.auth.get_csrf_cookie(session_id) + csrftoken = os.environ["LEETCODE_CSRF_TOKEN"] configuration = leetcode.configuration.Configuration() diff --git a/test/test_default_api.py b/test/test_default_api.py index 91b85bb..49af474 100644 --- a/test/test_default_api.py +++ b/test/test_default_api.py @@ -12,41 +12,120 @@ from __future__ import absolute_import +from time import sleep import unittest +from leetcode.models.interpretation import Interpretation +from leetcode.models.submission_id import SubmissionId +import test.base import leetcode from leetcode.api.default_api import DefaultApi # noqa: E501 from leetcode.rest import ApiException -class TestDefaultApi(unittest.TestCase): - """DefaultApi unit test stubs""" - - def setUp(self): - self.api = DefaultApi() # noqa: E501 +class TestDefaultApi(test.base.BaseTest): + def test_api_problems_topic_get(self): + result = self._api_instance.api_problems_topic_get(topic="algorithms") + test.base.validate_problems(result, "algorithms") - def tearDown(self): - pass + result = self._api_instance.api_problems_topic_get(topic="nonexistent") + test.base.validate_problems(result, "nonexistent") - def test_api_problems_topic_get(self): - """Test case for api_problems_topic_get""" - pass + try: + self._api_instance.api_problems_topic_get(topic="") + assert False + except ApiException as e: + assert e.status == 404 def test_graphql_post(self): - """Test case for graphql_post""" pass def test_problems_problem_interpret_solution_post(self): - """Test case for problems_problem_interpret_solution_post""" - pass + code = """ + class Solution: + def twoSum(self, nums, target): + print("stdout") + return [1] + """ + test_submission = leetcode.TestSubmission( + data_input="[2,7,11,15]\n9", + typed_code=code, + question_id=1, + test_mode=False, + lang="python", + ) + + result = self._api_instance.problems_problem_interpret_solution_post( + problem="two-sum", + body=test_submission, + ) + + assert isinstance(result, Interpretation) - def test_problems_problem_submit_post(self): - """Test case for problems_problem_submit_post""" - pass - def test_submissions_detail_id_check_get(self): - """Test case for submissions_detail_id_check_get""" - pass + def test_problems_problem_submit_post(self): + code = """ + class Solution: + def twoSum(self, nums, target): + print("stdout") + return [1] + """ + + submission = leetcode.Submission( + judge_type="large", + typed_code=code, + question_id=1, + test_mode=False, + lang="python", + ) + submission_id = self._api_instance.problems_problem_submit_post( + problem="two-sum", body=submission + ) + assert isinstance(submission_id, SubmissionId) + + + sleep(5) # FIXME: should probably be a busy-waiting loop + + submission_result = self._api_instance.submissions_detail_id_check_get( + id=submission_id.submission_id + ) + # assert isinstance( + # submission_result, + # leetcode.SubmissionResult, + # ) or isinstance( + # submission_result, + # leetcode.TestSubmissionResult, + # ) + + # if isinstance(submission_result, leetcode.SubmissionResult): + # assert isinstance(submission_result.compare_result, str) + # assert isinstance(submission_result.std_output, str) + # assert isinstance(submission_result.last_testcase, str) + # assert isinstance(submission_result.expected_output, str) + # assert isinstance(submission_result.input_formatted, str) + # assert isinstance(submission_result.input, str) + # # Missing or incorrect fields + # # task_name: str + # # finished: bool + # elif isinstance(submission_result, leetcode.TestSubmissionResult): + # assert isinstance(submission_result.code_answer, list) + # assert isinstance(submission_result.correct_answer, bool) + # assert isinstance(submission_result.expected_status_code, int) + # assert isinstance(submission_result.expected_lang, str) + # assert isinstance(submission_result.expected_run_success, bool) + # assert isinstance(submission_result.expected_status_runtime, str) + # assert isinstance(submission_result.expected_memory, int) + # assert isinstance(submission_result.expected_code_answer, list) + # assert isinstance(submission_result.expected_code_output, list) + # assert isinstance(submission_result.expected_elapsed_time, int) + # assert isinstance(submission_result.expected_task_finish_time, int) + + # def test_submissions_detail_id_check_get(self): + # try: + # self._api_instance.submissions_detail_id_check_get(id="nonexistent") + # assert False + # except ApiException as e: + # assert e.status == 404 if __name__ == "__main__": diff --git a/test/test_difficulty.py b/test/test_difficulty.py index ca69804..41baf05 100644 --- a/test/test_difficulty.py +++ b/test/test_difficulty.py @@ -13,13 +13,14 @@ from __future__ import absolute_import import unittest +import test.base import leetcode from leetcode.models.difficulty import Difficulty # noqa: E501 from leetcode.rest import ApiException -class TestDifficulty(unittest.TestCase): +class TestDifficulty(test.base.BaseTest): """Difficulty unit test stubs""" def setUp(self): @@ -29,9 +30,6 @@ def tearDown(self): pass def testDifficulty(self): - """Test Difficulty""" - # FIXME: construct object with mandatory attributes with example values - # model = leetcode.models.difficulty.Difficulty() # noqa: E501 pass diff --git a/test/test_graphql_request_get_question_detail.py b/test/test_graphql_request_get_question_detail.py index 6e3fc83..b4713f5 100644 --- a/test/test_graphql_request_get_question_detail.py +++ b/test/test_graphql_request_get_question_detail.py @@ -101,7 +101,8 @@ def test_request(self) -> None: assert question.title == "Two Sum" assert question.title_slug == "two-sum" assert question.frequency == 0.0 - assert question.freq_bar > 0 + if question.freq_bar is not None: # only available for premium users + assert question.freq_bar > 0 assert len(question.content) > 10 assert question.translated_title is None assert question.is_paid_only is False @@ -124,12 +125,13 @@ def test_request(self) -> None: assert question.topic_tags[0].translated_name is None assert len(topic_tag.typename) > 0 - tag_stat = list(json.loads(question.company_tag_stats).values())[0][0] + if question.company_tag_stats is not None: # only available for premium users + tag_stat = list(json.loads(question.company_tag_stats).values())[0][0] - assert tag_stat["taggedByAdmin"] in (True, False) - assert len(tag_stat["name"]) > 0 - assert len(tag_stat["slug"]) > 0 - assert tag_stat["timesEncountered"] > 0 + assert tag_stat["taggedByAdmin"] in (True, False) + assert len(tag_stat["name"]) > 0 + assert len(tag_stat["slug"]) > 0 + assert tag_stat["timesEncountered"] > 0 code_snippet = question.code_snippets[0] @@ -169,7 +171,7 @@ def test_request(self) -> None: assert question.has_solution in (True, False) assert question.has_video_solution in (True, False) - assert question.status in ("ac", "not_started", "tried") + assert question.status in ("ac", "not_started", "tried", None) assert len(question.sample_test_case) > 0 diff --git a/test/test_graphql_request_problemset_question_list.py b/test/test_graphql_request_problemset_question_list.py index c5801a4..e57771d 100644 --- a/test/test_graphql_request_problemset_question_list.py +++ b/test/test_graphql_request_problemset_question_list.py @@ -89,7 +89,7 @@ def test_request(self) -> None: variables=GraphqlQueryProblemsetQuestionListVariables( category_slug="algorithms", limit=1, - skip=2, + skip=3, filters=GraphqlQueryProblemsetQuestionListVariablesFilterInput( tags=["array"], difficulty="MEDIUM", @@ -125,7 +125,8 @@ def test_request(self) -> None: assert question.bound_topic_id is None assert question.title is not None assert question.frequency == 0.0 - assert question.freq_bar > 0 + if question.freq_bar is not None: # only available for premium users + assert question.freq_bar > 0 assert len(question.content) > 10 assert question.translated_title is None assert question.is_paid_only in (True, False) @@ -146,12 +147,13 @@ def test_request(self) -> None: assert question.topic_tags[0].translated_name is None assert len(topic_tag.typename) > 0 - tag_stat = list(json.loads(question.company_tag_stats).values())[0][0] + if question.company_tag_stats is not None: # only available for premium users + tag_stat = list(json.loads(question.company_tag_stats).values())[0][0] - assert tag_stat["taggedByAdmin"] in (True, False) - assert len(tag_stat["name"]) > 0 - assert len(tag_stat["slug"]) > 0 - assert tag_stat["timesEncountered"] > 0 + assert tag_stat["taggedByAdmin"] in (True, False) + assert len(tag_stat["name"]) > 0 + assert len(tag_stat["slug"]) > 0 + assert tag_stat["timesEncountered"] > 0 code_snippet = question.code_snippets[0] @@ -176,7 +178,9 @@ def test_request(self) -> None: assert len(code_definition["text"]) > 0 assert len(code_definition["defaultCode"]) > 0 - assert [len(hint) > 0 for hint in question.hints] + # TODO: check if the field has disappeared or if this is just a premium + # feature + # assert [len(hint) > 0 for hint in question.hints] question.solution