From 16dad0c6d7c30f43763eb5aa4a072d283987f764 Mon Sep 17 00:00:00 2001 From: Two Dev Date: Wed, 11 Dec 2024 12:52:07 +0700 Subject: [PATCH 1/3] update: pytest for unit tests, bug fixes. --- .coveragerc | 2 + .editorconfig | 23 +++++++ .github/workflows/ci.yml | 8 ++- CHANGELOG.md | 31 +++++++++- Makefile | 25 ++++++-- docs/advanced/hooks.md | 2 +- pyproject.toml | 3 + requirements-dev.txt | 24 ++++++++ requirements.txt | 22 +------ setup.cfg | 3 +- tests/conftest.py | 1 + tests/files/__init__.py | 0 tests/files/coingecko.png | Bin 0 -> 37229 bytes tests/test_api.py | 52 ++++++++++++++++ tests/test_auth.py | 102 ++++++++++++++++++++++++++++++++ tests/test_cookies.py | 29 +++++++++ tests/test_encoders.py | 94 +++++++++++++++++++++++++++++ tests/test_headers.py | 40 +++++++++++++ tests/test_hooks.py | 38 ++++++++++++ tests/test_params.py | 26 ++++++++ tests/test_redirects.py | 34 +++++++++++ tests/test_timeout.py | 16 +++++ tls_requests/__version__.py | 2 +- tls_requests/api.py | 1 + tls_requests/client.py | 2 +- tls_requests/exceptions.py | 4 ++ tls_requests/models/auth.py | 2 +- tls_requests/models/cookies.py | 2 +- tls_requests/models/encoders.py | 6 +- tls_requests/models/headers.py | 10 ++-- tls_requests/models/request.py | 15 ++--- tls_requests/models/response.py | 26 ++++---- tls_requests/models/tls.py | 31 +++++----- tls_requests/models/urls.py | 8 +-- tls_requests/types.py | 4 +- tox.ini | 9 +++ 36 files changed, 616 insertions(+), 81 deletions(-) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/files/__init__.py create mode 100644 tests/files/coingecko.png create mode 100644 tests/test_api.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_cookies.py create mode 100644 tests/test_encoders.py create mode 100644 tests/test_headers.py create mode 100644 tests/test_hooks.py create mode 100644 tests/test_params.py create mode 100644 tests/test_redirects.py create mode 100644 tests/test_timeout.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c712d25 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1c0d09e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml,xml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04d7e40..d7f8d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -24,11 +24,15 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-dev.txt - name: Run pre-commit uses: pre-commit/action@v3.0.0 + - name: Run tests + run: | + python -m pytest tests + deploy: needs: build runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 373b6b6..ea0b27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,35 @@ Release History =============== +1.0.3 (2024-12-11) +------------------- +**Improvements:** + +- Add unit tests. +- Improve document. + +**Bugfixes:** + +- Fix timeout. +- Fix missing port redirection. + + +1.0.3 (2024-12-05) +------------------- +**Improvements** + +- improve document. + +**Bugfixes** + +- Fix multipart encoders, cross share auth. + +1.0.2 (2024-12-05) +------------------- +**Improvements** +- Download specific TLS library versions. +- Add a document. + 1.0.1 (2024-12-04) ------------------- -* First release +- First release diff --git a/Makefile b/Makefile index 6a5a34c..2a51954 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ .PHONY: docs init: - python -m pip install -r requirements.txt + python -m pip install -r requirements-dev.txt + +test: + tox -p + rm -rf *.egg-info test-readme: python setup.py check --restructuredtext --strict && ([ $$? -eq 0 ] && echo "README.rst and CHANGELOG.md ok") || echo "Invalid markup in README.md or CHANGELOG.md!" @@ -10,11 +14,22 @@ lint: python -m isort tls_requests python -m flake8 tls_requests +coverage: + python -m pytest --cov-config .coveragerc --verbose --cov-report term --cov-report xml --cov=tls_requests tests + +docs: + mkdocs serve + +publish-test-pypi: + python -m pip install -r requirements-dev.txt + python -m pip install 'twine>=6.0.1' + python setup.py sdist bdist_wheel + twine upload --repository testpypi dist/* + rm -rf build dist .egg *.egg-info + publish-pypi: + python -m pip install -r requirements-dev.txt python -m pip install 'twine>=6.0.1' python setup.py sdist bdist_wheel twine upload dist/* - rm -rf build dist .egg wrapper_tls_requests.egg-info - -docs: - mkdocs serve + rm -rf build dist .egg *.egg-info diff --git a/docs/advanced/hooks.md b/docs/advanced/hooks.md index 1687f4a..b28b7ce 100644 --- a/docs/advanced/hooks.md +++ b/docs/advanced/hooks.md @@ -100,7 +100,7 @@ client.hooks = { Best Practices -------------- -1. **Access Content**: Use `.read()` or `await read()` in asynchronous contexts to access `response.content` before returning it. +1. **Access Content**: Use `.read()` or `await .aread()` in asynchronous contexts to access `response.content` before returning it. 2. **Always Use Lists:** Hooks must be registered as **lists of callables**, even if you are adding only one function. 3. **Combine Hooks:** You can register multiple hooks for the same event type to handle various concerns, such as logging and error handling. 4. **Order Matters:** Hooks are executed in the order they are registered. diff --git a/pyproject.toml b/pyproject.toml index 7fd0478..d2839f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ [build-system] requires = ['setuptools>=40.8.0'] build-backend = 'setuptools.build_meta' + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4bd2428 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,24 @@ +-r requirements.txt + +# Documentation +mkdocs==1.6.1 +mkautodoc==0.2.0 +mkdocs-material==9.5.39 + +# Packaging +setuptools~=75.3.0 +twine~=6.0.1 + +# Tests & Linting +pre-commit==3.7.0 +black==24.3.0 +coverage[toml]==7.6.1 +pre-commit==3.7.0 +isort==5.13.2 +mypy==1.11.2 +pytest==8.3.3 +pytest-asyncio==0.24.0 +pytest-cov==6.0.0 +pytest_httpserver==1.1.0 +Werkzeug==3.1.3 +tox==4.23.2 diff --git a/requirements.txt b/requirements.txt index dfac4f5..30babc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,5 @@ # Base chardet~=5.2.0 -requests~=2.32.3 +requests>=2.28.0 tqdm~=4.67.1 - -# Documentation -mkdocs==1.6.1 -mkautodoc==0.2.0 -mkdocs-material==9.5.39 - -# Packaging -setuptools~=75.3.0 -twine~=6.0.1 - -# Tests & Linting -pre-commit==3.7.0 -black==24.3.0 -flake8==7.0.0 -coverage[toml]==7.6.1 -pre-commit==3.7.0 -isort==5.13.2 -mypy==1.11.2 -pytest==8.3.3 +idna~=3.10 diff --git a/setup.cfg b/setup.cfg index 7c8cbb8..d5865a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ classifiers = Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy @@ -29,7 +30,7 @@ classifiers = project_urls = Changelog = https://github.com/thewebscraping/tls-requests/blob/main/CHANGELOG.md - Documentation = https://github.com/thewebscraping/tls-requests + Documentation = https://thewebscraping.github.io/tls-requests/ Source = https://github.com/thewebscraping/tls-requests Homepage = https://github.com/thewebscraping/tls-requests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d31eb2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ['pytest_httpserver', 'pytest_asyncio'] diff --git a/tests/files/__init__.py b/tests/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/files/coingecko.png b/tests/files/coingecko.png new file mode 100644 index 0000000000000000000000000000000000000000..43bc25c0f5ed14c7a473061bb2866b7dd8990dd7 GIT binary patch literal 37229 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 literal 0 HcmV?d00001 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cc26310 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,52 @@ +import tls_requests + +RESPONSE_BYTE = b"Hello World!" +RESPONSE_TEXT = "Hello World!" + + +def assert_response(response): + assert response.status_code, 200 + assert response.reason, "OK" + assert response.text, RESPONSE_TEXT + assert response.content, RESPONSE_BYTE + + +def make_request(request_fn, httpserver, is_assert_response: bool = True): + httpserver.expect_request("/api").respond_with_data(RESPONSE_BYTE) + response = request_fn(httpserver.url_for('/api')) + if is_assert_response: + assert_response(response) + + return response + + +def test_get(httpserver): + make_request(tls_requests.get, httpserver) + + +def test_post(httpserver): + make_request(tls_requests.post, httpserver) + + +def test_put(httpserver): + make_request(tls_requests.put, httpserver) + + +def test_patch(httpserver): + make_request(tls_requests.patch, httpserver) + + +def test_delete(httpserver): + make_request(tls_requests.delete, httpserver) + + +def test_options(httpserver): + make_request(tls_requests.options, httpserver) + + +def test_head(httpserver): + response = make_request(tls_requests.head, httpserver, False) + assert response.status_code == 200 + assert response.reason == "OK" + assert response.text == "" + assert response.content == b"" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..0ef845c --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,102 @@ +from base64 import b64encode + +import pytest + +import tls_requests + +auth = ("user", "pass") +AUTH_TOKEN = "Basic %s" % b64encode(b":".join([s.encode() for s in auth])).decode() +AUTH_HEADERS = {"authorization": AUTH_TOKEN} +AUTH_FUNCTION_KEY = "x-authorization" +AUTH_FUNCTION_VALUE = "123456" +AUTH_FUNCTION_HEADERS = {AUTH_FUNCTION_KEY: AUTH_FUNCTION_VALUE} + + +def auth_function(request): + request.headers.update(AUTH_FUNCTION_HEADERS) + + +@pytest.fixture +def auth_url(httpserver): + return httpserver.url_for('/auth') + + +@pytest.fixture +def http_auth_function(httpserver): + httpserver.expect_request("/auth", headers=AUTH_FUNCTION_HEADERS).respond_with_data() + return httpserver + + +@pytest.fixture +def http_auth(httpserver): + httpserver.expect_request("/auth", headers=AUTH_HEADERS).respond_with_data() + return httpserver + + +def test_auth(http_auth, auth_url): + response = tls_requests.get(auth_url, auth=auth) + assert response.status_code == 200 + assert response.request.headers["Authorization"] == AUTH_TOKEN + + +def test_auth_function(http_auth_function, auth_url): + response = tls_requests.get(auth_url, auth=auth_function) + assert response.status_code == 200 + assert response.request.headers[AUTH_FUNCTION_KEY] == AUTH_FUNCTION_VALUE + + +def test_client_auth(http_auth, auth_url): + with tls_requests.Client(auth=auth) as client: + response = client.get(auth_url) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers["Authorization"] == AUTH_TOKEN + + +def test_client_auth_cross_sharing(http_auth, auth_url): + with tls_requests.Client(auth=('1', '2')) as client: + response = client.get(auth_url, auth=auth) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers["Authorization"] == AUTH_TOKEN + + +def test_client_auth_function_cross_sharing(http_auth_function, auth_url): + with tls_requests.Client(auth=auth) as client: + response = client.get(auth_url, auth=auth_function) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers[AUTH_FUNCTION_KEY] == AUTH_FUNCTION_VALUE + + +@pytest.mark.asyncio +async def test_async_auth(http_auth, auth_url): + async with tls_requests.AsyncClient(auth=auth) as client: + response = await client.get(auth_url) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers["Authorization"] == AUTH_TOKEN + + +@pytest.mark.asyncio +async def test_async_auth_function(http_auth_function, auth_url): + async with tls_requests.AsyncClient(auth=auth_function) as client: + response = await client.get(auth_url) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers[AUTH_FUNCTION_KEY] == AUTH_FUNCTION_VALUE + + +@pytest.mark.asyncio +async def test_async_auth_function_cross_sharing(http_auth_function, auth_url): + async with tls_requests.AsyncClient(auth=auth) as client: + response = await client.get(auth_url, auth=auth_function) + + assert response.status_code == 200 + assert bool(response.closed == client.closed) is True + assert response.request.headers[AUTH_FUNCTION_KEY] == AUTH_FUNCTION_VALUE diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..4b0277f --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,29 @@ +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +import tls_requests + + +def hook_request_cookies(_request: Request, response: Response) -> Response: + for k, v in _request.cookies.items(): + response.set_cookie(k, v) + return response + + +def hook_response_cookies(_request: Request, response: Response) -> Response: + response.set_cookie("foo", "bar") + return response + + +def test_request_cookies(httpserver: HTTPServer): + httpserver.expect_request("/cookies").with_post_hook(hook_request_cookies).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/cookies"), cookies={"foo": "bar"}) + assert response.status_code == 200 + assert response.cookies.get("foo") == "bar" + + +def test_response_cookies(httpserver: HTTPServer): + httpserver.expect_request("/cookies").with_post_hook(hook_response_cookies).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/cookies")) + assert response.status_code == 200 + assert response.cookies.get("foo") == "bar" diff --git a/tests/test_encoders.py b/tests/test_encoders.py new file mode 100644 index 0000000..4c7e080 --- /dev/null +++ b/tests/test_encoders.py @@ -0,0 +1,94 @@ +from mimetypes import guess_type +from pathlib import Path + +import pytest +from pytest_httpserver import HTTPServer + +import tls_requests + +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent + +CHUNK_SIZE = 65_536 +FILENAME = BASE_DIR / 'tests' / 'files' / 'coingecko.png' + + +def get_image_bytes(filename: str = FILENAME): + response_bytes = b"" + with open(filename, 'rb') as f: + while chunk := f.read(CHUNK_SIZE): + response_bytes += chunk + + return response_bytes + + +@pytest.fixture +def mimetype(filename: str = FILENAME): + return guess_type(filename)[0] + + +@pytest.fixture +def file_bytes(filename: str = FILENAME) -> bytes: + return get_image_bytes() + + +def hook_files(_request, response): + image = _request.files['image'] + image_bytes = b"".join(image) + origin_bytes = get_image_bytes() + response.headers['X-Image'] = 1 if image_bytes == origin_bytes else 0 + response.headers['X-Image-Content-Type'] = image.content_type + return response + + +def hook_multipart(_request, response): + response.headers["X-Data-Values"] = ", ".join(_request.form.getlist('key1')) + response.headers["X-Image-Content-Type"] = _request.files["image"].content_type + return response + + +def test_file(httpserver: HTTPServer): + httpserver.expect_request("/files").with_post_hook(hook_files).respond_with_data(status=201) + files = {'image': open(FILENAME, 'rb')} + response = tls_requests.post(httpserver.url_for("/files"), files=files) + assert response.status_code == 201 + assert response.headers.get('X-Image') == '1' + + +def test_file_tuple_2(httpserver: HTTPServer): + httpserver.expect_request("/files").with_post_hook(hook_files).respond_with_data(status=201) + files = {'image': ('coingecko.png', open(FILENAME, 'rb'))} + response = tls_requests.post(httpserver.url_for("/files"), files=files) + assert response.status_code == 201 + assert response.headers.get('X-Image') == '1' + + +def test_file_tuple_3(httpserver: HTTPServer): + httpserver.expect_request("/files").with_post_hook(hook_files).respond_with_data(status=201) + files = {'image': ('coingecko.png', open(FILENAME, 'rb'), 'image/png')} + response = tls_requests.post(httpserver.url_for("/files"), files=files) + assert response.status_code == 201 + assert response.headers.get('X-Image') == '1' + assert response.headers.get('X-Image-Content-Type') == 'image/png' + + +def test_multipart(httpserver: HTTPServer, file_bytes, mimetype): + data = {'key1': ['value1', 'value2']} + httpserver.expect_request("/multipart").with_post_hook(hook_multipart).respond_with_data(status=201) + files = {'image': ('coingecko.png', open(FILENAME, 'rb'), 'image/png')} + response = tls_requests.post(httpserver.url_for("/multipart"), data=data, files=files) + assert response.status_code == 201 + assert response.headers["X-Image-Content-Type"] == "image/png" + assert response.headers["X-Data-Values"] == ", ".join(data["key1"]) + + +def test_json(httpserver: HTTPServer): + data = { + 'integer': 1, + 'boolean': True, + 'list': ['1', '2', '3'], + 'data': {'key': 'value'} + } + httpserver.expect_request("/json", json=data).respond_with_data(b"OK", status=201) + response = tls_requests.post(httpserver.url_for("/json"), json=data) + assert response.status_code == 201 + assert response.content == b"OK" diff --git a/tests/test_headers.py b/tests/test_headers.py new file mode 100644 index 0000000..49f764c --- /dev/null +++ b/tests/test_headers.py @@ -0,0 +1,40 @@ +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +import tls_requests + + +def hook_request_headers(_request: Request, response: Response) -> Response: + response.headers = _request.headers + return response + + +def hook_response_headers(_request: Request, response: Response) -> Response: + response.headers["foo"] = "bar" + return response + + +def hook_response_case_insensitive_headers(_request: Request, response: Response) -> Response: + response.headers["Foo"] = "bar" + return response + + +def test_request_headers(httpserver: HTTPServer): + httpserver.expect_request("/headers").with_post_hook(hook_request_headers).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/headers"), headers={"foo": "bar"}) + assert response.status_code == 200 + assert response.headers.get("foo") == "bar" + + +def test_response_headers(httpserver: HTTPServer): + httpserver.expect_request("/headers").with_post_hook(hook_response_headers).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/headers")) + assert response.status_code, 200 + assert response.headers.get("foo") == "bar" + + +def test_response_case_insensitive_headers(httpserver: HTTPServer): + httpserver.expect_request("/headers").with_post_hook(hook_response_case_insensitive_headers).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/headers")) + assert response.status_code, 200 + assert response.headers.get("foo") == "bar" diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..eb2808b --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,38 @@ +from pytest_httpserver import HTTPServer + +import tls_requests + + +def log_request_return(request): + request.headers["X-Hook"] = '123456' + return request + + +def log_request_no_return(request): + request.headers["X-Hook"] = '123456' + + +def log_response_raise_on_4xx_5xx(response): + response.raise_for_status() + + +def test_request_hook(httpserver: HTTPServer): + httpserver.expect_request("/hooks").respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/hooks"), hooks={"request": [log_request_return]}) + assert response.status_code == 200 + assert response.request.headers.get("X-Hook") == "123456" + + +def test_request_hook_no_return(httpserver: HTTPServer): + httpserver.expect_request("/hooks").respond_with_data(b"OK") + _ = tls_requests.get(httpserver.url_for("/hooks"), hooks={"request": [log_request_no_return]}) + assert response.status_code == 200 + assert response.request.headers.get("X-Hook") == "123456" + + +def test_response_hook(httpserver: HTTPServer): + httpserver.expect_request("/hooks", ).respond_with_data(status=404) + try: + _ = tls_requests.get(httpserver.url_for("/hooks"), hooks={"response": [log_response_raise_on_4xx_5xx]}) + except Exception as e: + assert e, tls_requests.exceptions.HTTPError diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..b21afe2 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,26 @@ +from urllib.parse import unquote + +from pytest_httpserver import HTTPServer + +import tls_requests + + +def request_hook(_request, response): + response.headers['x-path'] = _request.full_path + return response + + +def test_request_params(httpserver: HTTPServer): + params = {"a": "1", "b": "2"} + httpserver.expect_request("/params").with_post_hook(request_hook).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/params"), params=params) + assert response.status_code == 200 + assert unquote(str(response.url)).endswith(unquote(response.headers["x-path"])) + + +def test_request_multi_params(httpserver: HTTPServer): + params = {"a": ["1", "2", "3"]} + httpserver.expect_request("/params").with_post_hook(request_hook).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/params"), params=params) + assert response.status_code == 200 + assert unquote(str(response.url)).endswith(unquote(response.headers["x-path"])) diff --git a/tests/test_redirects.py b/tests/test_redirects.py new file mode 100644 index 0000000..13d4598 --- /dev/null +++ b/tests/test_redirects.py @@ -0,0 +1,34 @@ +from pytest_httpserver import HTTPServer + +import tls_requests + + +def test_missing_host_redirects(httpserver: HTTPServer): + httpserver.expect_request("/redirects/3").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/1"}) + httpserver.expect_request("/redirects/1").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/2"}) + httpserver.expect_request("/redirects/2").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/ok"}) + httpserver.expect_request("/redirects/ok").respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/redirects/3")) + assert response.status_code == 200 + assert len(response.history) == 3 + + +def test_full_path_redirects(httpserver: HTTPServer): + httpserver.expect_request("/redirects/3").respond_with_data(b"OK", status=302, headers={"Location": httpserver.url_for("/redirects/1")}) + httpserver.expect_request("/redirects/1").respond_with_data(b"OK", status=302, headers={"Location": httpserver.url_for("/redirects/2")}) + httpserver.expect_request("/redirects/2").respond_with_data(b"OK", status=302, headers={"Location": httpserver.url_for("/redirects/ok")}) + httpserver.expect_request("/redirects/ok").respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/redirects/3")) + assert response.status_code == 200 + assert len(response.history) == 3 + + +def test_too_many_redirects(httpserver: HTTPServer): + httpserver.expect_request("/redirects/3").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/1"}) + httpserver.expect_request("/redirects/1").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/2"}) + httpserver.expect_request("/redirects/2").respond_with_data(b"OK", status=302, headers={"Location": "/redirects/3"}) + httpserver.expect_request("/redirects/ok").respond_with_data(b"OK") + try: + _ = tls_requests.get(httpserver.url_for("/redirects/3")) + except Exception as e: + assert isinstance(e, tls_requests.exceptions.TooManyRedirects) diff --git a/tests/test_timeout.py b/tests/test_timeout.py new file mode 100644 index 0000000..030f956 --- /dev/null +++ b/tests/test_timeout.py @@ -0,0 +1,16 @@ +import time + +from pytest_httpserver import HTTPServer + +import tls_requests + + +def timeout_hook(_request, response): + time.sleep(3) + return response + + +def test_timeout(httpserver: HTTPServer): + httpserver.expect_request("/timeout").with_post_hook(timeout_hook).respond_with_data(b"OK") + response = tls_requests.get(httpserver.url_for("/timeout"), timeout=1) + assert response.status_code == 0 diff --git a/tls_requests/__version__.py b/tls_requests/__version__.py index a6eac23..88079c5 100644 --- a/tls_requests/__version__.py +++ b/tls_requests/__version__.py @@ -3,5 +3,5 @@ __url__ = "https://github.com/thewebscraping/tls-requests" __author__ = "Tu Pham" __author_email__ = "thetwofarm@gmail.com" -__version__ = "1.0.3" +__version__ = "1.0.4" __license__ = "MIT" diff --git a/tls_requests/api.py b/tls_requests/api.py index 988c367..11c625c 100644 --- a/tls_requests/api.py +++ b/tls_requests/api.py @@ -93,6 +93,7 @@ def request( headers=headers, auth=auth, follow_redirects=follow_redirects, + timeout=timeout, ) diff --git a/tls_requests/client.py b/tls_requests/client.py index 2d39ac5..a79c868 100644 --- a/tls_requests/client.py +++ b/tls_requests/client.py @@ -313,7 +313,7 @@ def _rebuild_redirect_url(self, request: Request, response: Response) -> URL: except KeyError: raise RemoteProtocolError("Invalid URL in Location headers: %s" % e) - for missing_field in ["scheme", "host", "fragment"]: + for missing_field in ["scheme", "host", "port", "fragment"]: private_field = "_%s" % missing_field if not getattr(url, private_field, None): setattr(url, private_field, getattr(request.url, private_field, "")) diff --git a/tls_requests/exceptions.py b/tls_requests/exceptions.py index 050d9ba..83eb6e3 100644 --- a/tls_requests/exceptions.py +++ b/tls_requests/exceptions.py @@ -51,6 +51,10 @@ class URLError(HTTPError): pass +class ProxyError(HTTPError): + pass + + class URLParamsError(URLError): pass diff --git a/tls_requests/models/auth.py b/tls_requests/models/auth.py index add0653..63089e1 100644 --- a/tls_requests/models/auth.py +++ b/tls_requests/models/auth.py @@ -1,7 +1,7 @@ from base64 import b64encode from typing import Any, Union -from .request import Request +from tls_requests.models.request import Request class Auth: diff --git a/tls_requests/models/cookies.py b/tls_requests/models/cookies.py index dd2a532..a6e0d24 100644 --- a/tls_requests/models/cookies.py +++ b/tls_requests/models/cookies.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Iterator, MutableMapping from urllib.parse import urlparse, urlunparse -from ..exceptions import CookieConflictError +from tls_requests.exceptions import CookieConflictError if TYPE_CHECKING: from .request import Request diff --git a/tls_requests/models/encoders.py b/tls_requests/models/encoders.py index b226af0..267ad04 100644 --- a/tls_requests/models/encoders.py +++ b/tls_requests/models/encoders.py @@ -5,9 +5,9 @@ from typing import Any, AsyncIterator, Iterator, Mapping, TypeVar from urllib.parse import urlencode -from ..types import (BufferTypes, ByteOrStr, RequestData, RequestFiles, - RequestFileValue, RequestJson) -from ..utils import to_bytes, to_str +from tls_requests.types import (BufferTypes, ByteOrStr, RequestData, + RequestFiles, RequestFileValue, RequestJson) +from tls_requests.utils import to_bytes, to_str __all__ = [ "JsonEncoder", diff --git a/tls_requests/models/headers.py b/tls_requests/models/headers.py index 0c5cf2b..27f3e10 100644 --- a/tls_requests/models/headers.py +++ b/tls_requests/models/headers.py @@ -1,14 +1,14 @@ from abc import ABC from collections.abc import Mapping, MutableMapping from enum import Enum -from typing import Any, ItemsView, KeysView, Literal, TypeAlias, ValuesView +from typing import Any, ItemsView, KeysView, Literal, ValuesView -from ..types import ByteOrStr, HeaderTypes -from ..utils import to_str +from tls_requests.types import ByteOrStr, HeaderTypes +from tls_requests.utils import to_str __all__ = ["Headers"] -HeaderAliasTypes: TypeAlias = Literal["*", "lower", "capitalize"] +HeaderAliasTypes = Literal["*", "lower", "capitalize"] class HeaderAlias(str, Enum): @@ -156,5 +156,5 @@ def __repr__(self): ] return "<%s: %s>" % ( self.__class__.__name__, - {"[secure]" if k in SECURE else k: ",".join(v) for k, v in self._items}, + {k: "[secure]" if k in SECURE else ",".join(v) for k, v in self._items}, ) diff --git a/tls_requests/models/request.py b/tls_requests/models/request.py index 1d12542..24a8be2 100644 --- a/tls_requests/models/request.py +++ b/tls_requests/models/request.py @@ -1,12 +1,13 @@ from typing import Any -from ..settings import DEFAULT_TIMEOUT -from ..types import (CookieTypes, HeaderTypes, MethodTypes, RequestData, - RequestFiles, TimeoutTypes, URLParamTypes, URLTypes) -from .cookies import Cookies -from .encoders import StreamEncoder -from .headers import Headers -from .urls import URL +from tls_requests.models.cookies import Cookies +from tls_requests.models.encoders import StreamEncoder +from tls_requests.models.headers import Headers +from tls_requests.models.urls import URL +from tls_requests.settings import DEFAULT_TIMEOUT +from tls_requests.types import (CookieTypes, HeaderTypes, MethodTypes, + RequestData, RequestFiles, TimeoutTypes, + URLParamTypes, URLTypes) __all__ = ["Request"] diff --git a/tls_requests/models/response.py b/tls_requests/models/response.py index 9226fed..af49feb 100644 --- a/tls_requests/models/response.py +++ b/tls_requests/models/response.py @@ -3,16 +3,16 @@ from email.message import Message from typing import Any, Callable, Optional, TypeVar, Union -from ..exceptions import HTTPError -from ..settings import CHUNK_SIZE -from ..types import CookieTypes, HeaderTypes, ResponseHistory -from ..utils import chardet, to_json -from .cookies import Cookies -from .encoders import StreamEncoder -from .headers import Headers -from .request import Request -from .status_codes import StatusCodes -from .tls import TLSResponse +from tls_requests.exceptions import HTTPError +from tls_requests.models.cookies import Cookies +from tls_requests.models.encoders import StreamEncoder +from tls_requests.models.headers import Headers +from tls_requests.models.request import Request +from tls_requests.models.status_codes import StatusCodes +from tls_requests.models.tls import TLSResponse +from tls_requests.settings import CHUNK_SIZE +from tls_requests.types import CookieTypes, HeaderTypes, ResponseHistory +from tls_requests.utils import chardet, to_json __all__ = ["Response"] @@ -129,7 +129,7 @@ def text(self) -> str: return self._text @property - def charset(self) -> str | None: + def charset(self) -> Optional[str]: if self.headers.get("Content-Type"): msg = Message() msg["content-type"] = self.headers["Content-Type"] @@ -217,6 +217,10 @@ async def aread(self) -> bytes: self._content = b"".join([chunk async for chunk in stream]) return self._content + @property + def closed(self): + return self._is_closed + def close(self) -> None: if not self._is_closed: self._is_closed = True diff --git a/tls_requests/models/tls.py b/tls_requests/models/tls.py index 70bb344..8b87e4d 100644 --- a/tls_requests/models/tls.py +++ b/tls_requests/models/tls.py @@ -4,14 +4,15 @@ from dataclasses import fields as get_fields from typing import Any, Optional, TypeVar, Union -from ..settings import (DEFAULT_HEADERS, DEFAULT_TIMEOUT, DEFAULT_TLS_DEBUG, - DEFAULT_TLS_HTTP2, DEFAULT_TLS_IDENTIFIER) -from ..types import (MethodTypes, TLSCookiesTypes, TLSIdentifierTypes, - TLSSessionId, URLTypes) -from ..utils import to_base64, to_bytes, to_json -from .encoders import StreamEncoder -from .libraries import TLSLibrary -from .status_codes import StatusCodes +from tls_requests.models.encoders import StreamEncoder +from tls_requests.models.libraries import TLSLibrary +from tls_requests.models.status_codes import StatusCodes +from tls_requests.settings import (DEFAULT_HEADERS, DEFAULT_TIMEOUT, + DEFAULT_TLS_DEBUG, DEFAULT_TLS_HTTP2, + DEFAULT_TLS_IDENTIFIER) +from tls_requests.types import (MethodTypes, TLSCookiesTypes, + TLSIdentifierTypes, TLSSessionId, URLTypes) +from tls_requests.utils import to_base64, to_bytes, to_json __all__ = [ "TLSClient", @@ -158,7 +159,7 @@ def _send(cls, fn: callable, payload: dict): return cls.response(fn(to_bytes(payload))) -@dataclass(kw_only=True) +@dataclass class _BaseConfig: """Base configuration for TLSSession""" @@ -182,7 +183,7 @@ def to_payload(self) -> dict: return self.to_dict() -@dataclass(kw_only=True) +@dataclass class TLSResponse(_BaseConfig): """TLS Response @@ -229,7 +230,7 @@ def __repr__(self): return "" % self.status -@dataclass(kw_only=True) +@dataclass class TLSRequestCookiesConfig(_BaseConfig): """ Request Cookies Configuration @@ -255,7 +256,7 @@ class TLSRequestCookiesConfig(_BaseConfig): value: str -@dataclass(kw_only=True) +@dataclass class CustomTLSClientConfig(_BaseConfig): """ Custom TLS Client Configuration @@ -341,7 +342,7 @@ class CustomTLSClientConfig(_BaseConfig): supportedVersions: list[str] = None -@dataclass(kw_only=True) +@dataclass class TLSConfig(_BaseConfig): """TLS Configuration @@ -424,7 +425,7 @@ class TLSConfig(_BaseConfig): requestMethod: MethodTypes = None requestUrl: Optional[str] = None sessionId: str = field(default_factory=lambda: str(uuid.uuid4())) - timeoutSeconds: int = 10 + timeoutSeconds: int = 30 tlsClientIdentifier: Optional[TLSIdentifierTypes] = DEFAULT_TLS_IDENTIFIER withDebug: bool = False withDefaultCookieJar: bool = False @@ -482,7 +483,7 @@ def copy_with( isByteRequest=is_byte_request, proxyUrl=proxy, forceHttp1=not http2, - timeoutSeconds=None, + timeoutSeconds=timeout, insecureSkipVerify=not verify, tlsClientIdentifier=tls_identifier, withDebug=tls_debug, diff --git a/tls_requests/models/urls.py b/tls_requests/models/urls.py index 6438b8c..b6a6a9e 100644 --- a/tls_requests/models/urls.py +++ b/tls_requests/models/urls.py @@ -7,8 +7,8 @@ import idna -from ..exceptions import URLError, URLParamsError -from ..types import URL_ALLOWED_PARAMS, URLParamTypes +from tls_requests.exceptions import ProxyError, URLError, URLParamsError +from tls_requests.types import URL_ALLOWED_PARAMS, URLParamTypes __all__ = ["URL", "URLParams", "Proxy"] @@ -91,7 +91,7 @@ def _prepare(self, params: URLParamTypes = None, **kwargs) -> Mapping: return params def normalize(self, s: URL_ALLOWED_PARAMS): - if not isinstance(s, URL_ALLOWED_PARAMS): + if not isinstance(s, (str, bytes, int, float, bool)): raise URLParamsError("Invalid parameters value type.") if isinstance(s, bool): @@ -274,7 +274,7 @@ class Proxy(URL): def scheme(self) -> str: if self._scheme is None: if str(self.parsed.scheme).lower() not in self.ALLOWED_SCHEMES: - raise ValueError("Invalid scheme.") + raise ProxyError("Invalid scheme.") self._scheme = self.parsed.scheme diff --git a/tls_requests/types.py b/tls_requests/types.py index 4318328..001f775 100644 --- a/tls_requests/types.py +++ b/tls_requests/types.py @@ -4,7 +4,7 @@ from http.cookiejar import CookieJar from typing import (IO, TYPE_CHECKING, Any, BinaryIO, Callable, List, Literal, - Mapping, Optional, Sequence, Tuple, TypeAlias, Union) + Mapping, Optional, Sequence, Tuple, Union) from uuid import UUID if TYPE_CHECKING: # pragma: no cover @@ -43,7 +43,7 @@ TLSSessionId = Union[str, UUID] TLSPayload = Union[dict, str, bytes, bytearray] TLSCookiesTypes = Optional[List[dict[str, str]]] -TLSIdentifierTypes: TypeAlias = Literal[ +TLSIdentifierTypes = Literal[ "chrome_103", "chrome_104", "chrome_105", diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0ff11cc --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py{39,310,311,312,313}-{default, use_chardet_on_py3} + +[testenv] +deps = -r requirements-dev.txt +commands = + pytest {posargs:tests} + +[testenv:default] From 819601112852d221591fb681de07150b74bd5f7d Mon Sep 17 00:00:00 2001 From: Two Dev Date: Wed, 11 Dec 2024 13:03:20 +0700 Subject: [PATCH 2/3] chore: CI --- .github/workflows/ci.yml | 7 ++++--- requirements-dev.txt | 1 + tests/test_hooks.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7f8d35..6b61e98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,11 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: Run pre-commit - uses: pre-commit/action@v3.0.0 + - name: Lint + run: | + make lint - - name: Run tests + - name: Tests run: | python -m pytest tests diff --git a/requirements-dev.txt b/requirements-dev.txt index 4bd2428..67bd5a1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -15,6 +15,7 @@ black==24.3.0 coverage[toml]==7.6.1 pre-commit==3.7.0 isort==5.13.2 +flake8==7.1.1 mypy==1.11.2 pytest==8.3.3 pytest-asyncio==0.24.0 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index eb2808b..79e2d58 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -25,7 +25,7 @@ def test_request_hook(httpserver: HTTPServer): def test_request_hook_no_return(httpserver: HTTPServer): httpserver.expect_request("/hooks").respond_with_data(b"OK") - _ = tls_requests.get(httpserver.url_for("/hooks"), hooks={"request": [log_request_no_return]}) + response = tls_requests.get(httpserver.url_for("/hooks"), hooks={"request": [log_request_no_return]}) assert response.status_code == 200 assert response.request.headers.get("X-Hook") == "123456" From 501c53d1f483a2a73e6bad781143bc0fc2c95bdb Mon Sep 17 00:00:00 2001 From: Two Dev Date: Wed, 11 Dec 2024 13:11:53 +0700 Subject: [PATCH 3/3] chore: CI --- .github/workflows/ci.yml | 8 ++++---- Makefile | 8 ++++++-- README.md | 4 ++-- requirements.txt | 2 +- tests/conftest.py | 6 ++++++ tls_requests/models/libraries.py | 21 +++++++++++++++------ tox.ini | 2 +- 7 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b61e98..a1105a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,8 @@ jobs: build: runs-on: ubuntu-latest strategy: - max-parallel: 4 + fail-fast: false + max-parallel: 3 matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: @@ -23,8 +24,7 @@ jobs: - name: Install Dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + make init - name: Lint run: | @@ -32,7 +32,7 @@ jobs: - name: Tests run: | - python -m pytest tests + make pytest deploy: needs: build diff --git a/Makefile b/Makefile index 2a51954..4f71cbc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .PHONY: docs init: + python -m pip install --upgrade pip python -m pip install -r requirements-dev.txt test: @@ -14,6 +15,9 @@ lint: python -m isort tls_requests python -m flake8 tls_requests +pytest: + python -m pytest tests + coverage: python -m pytest --cov-config .coveragerc --verbose --cov-report term --cov-report xml --cov=tls_requests tests @@ -25,11 +29,11 @@ publish-test-pypi: python -m pip install 'twine>=6.0.1' python setup.py sdist bdist_wheel twine upload --repository testpypi dist/* - rm -rf build dist .egg *.egg-info + rm -rf build dist .egg wrapper_tls_requests.egg-info publish-pypi: python -m pip install -r requirements-dev.txt python -m pip install 'twine>=6.0.1' python setup.py sdist bdist_wheel twine upload dist/* - rm -rf build dist .egg *.egg-info + rm -rf build dist .egg wrapper_tls_requests.egg-info diff --git a/README.md b/README.md index c1e33d3..a24127f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# TLS REQUESTS -**A powerful and lightweight Python library for making secure and reliable HTTP/TLS Fingerprint requests.** +# TLS Requests +TLS Requests is a powerful Python library for secure HTTP requests, offering browser-like TLS fingerprinting, anti-bot bypassing, and high performance. * * * diff --git a/requirements.txt b/requirements.txt index 30babc9..7a91425 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Base chardet~=5.2.0 -requests>=2.28.0 +requests~=2.32.3 tqdm~=4.67.1 idna~=3.10 diff --git a/tests/conftest.py b/tests/conftest.py index 3d31eb2..95feb18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,7 @@ +import tls_requests + pytest_plugins = ['pytest_httpserver', 'pytest_asyncio'] + + +def pytest_configure(config): + tls_requests.TLSLibrary.load() diff --git a/tls_requests/models/libraries.py b/tls_requests/models/libraries.py index e1fc044..0b74417 100644 --- a/tls_requests/models/libraries.py +++ b/tls_requests/models/libraries.py @@ -15,8 +15,13 @@ BIN_DIR = os.path.join(Path(__file__).resolve(strict=True).parent.parent / "bin") GITHUB_API_URL = "https://api.github.com/repos/bogdanfinn/tls-client/releases" +OS_PLATFORM = platform +OS_MACHINE = machine() +if OS_PLATFORM == "linux" and OS_MACHINE == "x86_64": + OS_MACHINE = "amd64" + PATTERN_RE = re.compile( - r"xgo[a-zA-Z0-9.-]+%s-%s\.(so|dll|dylib)" % (platform, machine()), re.I + r"[a-zA-Z0-9.-]+%s-%s\.(so|dll|dylib)" % (OS_PLATFORM, OS_MACHINE), re.I ) @@ -58,6 +63,7 @@ def from_kwargs(cls, **kwargs): class TLSLibrary: @classmethod def fetch_api(cls, version: str = None, retries: int = 3): + for _ in range(retries): try: response = requests.get(GITHUB_API_URL) @@ -70,14 +76,16 @@ def fetch_api(cls, version: str = None, retries: int = 3): asset for release in releases for asset in release.assets - if "xgo" in str(asset.browser_download_url) + if PATTERN_RE.search(asset.browser_download_url) ] if version is not None: for asset in assets: if str(version) == asset.name: - return [asset.browser_download_url] + yield asset.browser_download_url + + for asset in assets: + yield asset.browser_download_url - return [asset.browser_download_url for asset in assets] except Exception as e: print("Unable to fetch GitHub API: %s" % e) @@ -96,11 +104,12 @@ def find_all(cls) -> list[str]: @classmethod def download(cls, version: str = None) -> str: try: + print("System Info - Platform: %s, Machine: %s." % (OS_PLATFORM, OS_MACHINE)) download_url = None for download_url in cls.fetch_api(version): - if PATTERN_RE.search(download_url): - break + break + print("Library Download URL: %s" % download_url) if download_url: destination = os.path.join(BIN_DIR, download_url.split("/")[-1]) with requests.get(download_url, stream=True) as response: diff --git a/tox.ini b/tox.ini index 0ff11cc..0ea49bd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{39,310,311,312,313}-{default, use_chardet_on_py3} +envlist = py{39,310,311,312,313}-default [testenv] deps = -r requirements-dev.txt