From 5dee6126a1111f951711421607dc8d2d2d8ca61e Mon Sep 17 00:00:00 2001 From: jahwag <540380+jahwag@users.noreply.github.com> Date: Mon, 19 Aug 2024 17:43:51 +0200 Subject: [PATCH] Fix ClaudeSync 403 Error on Python 3.12.5 with Claude.ai (#49) This update allows ClaudeSync to run with the provider Claude.ai on Python 3.12.5 by switching the HTTP client from requests to urllib. --- .github/workflows/python-package.yml | 82 ++++----- pyproject.toml | 3 +- requirements.txt | 1 - src/claudesync/providers/base_claude_ai.py | 12 +- src/claudesync/providers/claude_ai.py | 99 ++++++----- src/claudesync/utils.py | 7 +- tests/providers/test_base_claude_ai.py | 194 ++++++++++----------- tests/providers/test_claude_ai.py | 133 ++++++++------ 8 files changed, 292 insertions(+), 239 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a1ed445..92f6f79 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,41 +1,41 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python package - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 1 - strategy: - fail-fast: false - matrix: - python-version: ["3.9", "3.10", "3.11"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -e . - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 1 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -e . + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/pyproject.toml b/pyproject.toml index b05d3bd..55f1f1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claudesync" -version = "0.5.0" +version = "0.5.1" authors = [ {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, ] @@ -15,7 +15,6 @@ classifiers = [ ] dependencies = [ "Click>=8.1.7", - "requests>=2.32.3", "pathspec>=0.12.1", "crontab>=1.0.1", "click_completion>=0.5.2", diff --git a/requirements.txt b/requirements.txt index b5f5e25..998062e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Click>=8.1.7 -requests>=2.32.3 pathspec>=0.12.1 crontab>=1.0.1 click_completion>=0.5.2 diff --git a/src/claudesync/providers/base_claude_ai.py b/src/claudesync/providers/base_claude_ai.py index 930845c..4ea6934 100644 --- a/src/claudesync/providers/base_claude_ai.py +++ b/src/claudesync/providers/base_claude_ai.py @@ -21,7 +21,7 @@ def _get_session_key_expiry(): ) + datetime.timedelta(days=30) formatted_expires = default_expires.strftime(date_format).strip() expires = click.prompt( - "Please enter the expires time for the sessionKey", + "Please enter the expires time for the sessionKey (optional)", default=formatted_expires, type=str, ).strip() @@ -71,7 +71,9 @@ def login(self): ) while True: - session_key = click.prompt("Please enter your sessionKey", type=str) + session_key = click.prompt( + "Please enter your sessionKey", type=str, hide_input=True + ) if not session_key.startswith("sk-ant"): click.echo( "Invalid sessionKey format. Please make sure it starts with 'sk-ant'." @@ -105,8 +107,10 @@ def get_organizations(self): return [ {"id": org["uuid"], "name": org["name"]} for org in response - if ({"chat", "claude_pro"}.issubset(set(org.get("capabilities", []))) or - {"chat", "raven"}.issubset(set(org.get("capabilities", [])))) + if ( + {"chat", "claude_pro"}.issubset(set(org.get("capabilities", []))) + or {"chat", "raven"}.issubset(set(org.get("capabilities", []))) + ) ] def get_projects(self, organization_id, include_archived=False): diff --git a/src/claudesync/providers/claude_ai.py b/src/claudesync/providers/claude_ai.py index dff03cb..53b3d56 100644 --- a/src/claudesync/providers/claude_ai.py +++ b/src/claudesync/providers/claude_ai.py @@ -1,5 +1,8 @@ +import urllib.request +import urllib.error +import urllib.parse import json -import requests +import gzip from .base_claude_ai import BaseClaudeAIProvider from ..exceptions import ProviderError @@ -8,24 +11,13 @@ class ClaudeAIProvider(BaseClaudeAIProvider): def _make_request(self, method, endpoint, data=None): url = f"{self.BASE_URL}{endpoint}" headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Origin": "https://claude.ai", - "Referer": "https://claude.ai/projects", - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, zstd", - "Accept-Language": "en-US,en;q=0.5", - "anthropic-client-sha": "unknown", - "anthropic-client-version": "unknown", - "Connection": "keep-alive", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0", + "Content-Type": "application/json", + "Accept-Encoding": "gzip", } cookies = { "sessionKey": self.session_key, - "CH-prefers-color-scheme": "dark", - "anthropic-consent-preferences": '{"analytics":true,"marketing":true}', } try: @@ -35,40 +27,63 @@ def _make_request(self, method, endpoint, data=None): if data: self.logger.debug(f"Request data: {data}") - response = requests.request( - method, url, headers=headers, cookies=cookies, json=data - ) + # Prepare the request + req = urllib.request.Request(url, method=method) + for key, value in headers.items(): + req.add_header(key, value) + + # Add cookies + cookie_string = "; ".join([f"{k}={v}" for k, v in cookies.items()]) + req.add_header("Cookie", cookie_string) + + # Add data if present + if data: + json_data = json.dumps(data).encode("utf-8") + req.data = json_data - self.logger.debug(f"Response status code: {response.status_code}") - self.logger.debug(f"Response headers: {response.headers}") - self.logger.debug(f"Response content: {response.text[:1000]}...") + # Make the request + with urllib.request.urlopen(req) as response: + self.logger.debug(f"Response status code: {response.status}") + self.logger.debug(f"Response headers: {response.headers}") - if response.status_code == 403: - error_msg = ( - "Received a 403 Forbidden error. Your session key might be invalid. " - "Please try logging out and logging in again. If the issue persists, " - "you can try using the claude.ai-curl provider as a workaround:\n" - "claudesync api logout\n" - "claudesync api login claude.ai-curl" - ) - self.logger.error(error_msg) - raise ProviderError(error_msg) + # Handle gzip encoding + if response.headers.get("Content-Encoding") == "gzip": + content = gzip.decompress(response.read()) + else: + content = response.read() - response.raise_for_status() + content_str = content.decode("utf-8") + self.logger.debug(f"Response content: {content_str[:1000]}...") - if not response.content: - return None + if not content: + return None - return response.json() + return json.loads(content_str) - except requests.RequestException as e: - self.logger.error(f"Request failed: {str(e)}") - if hasattr(e, "response") and e.response is not None: - self.logger.error(f"Response status code: {e.response.status_code}") - self.logger.error(f"Response headers: {e.response.headers}") - self.logger.error(f"Response content: {e.response.text}") + except urllib.error.HTTPError as e: + self.handle_http_error(e) + except urllib.error.URLError as e: + self.logger.error(f"URL Error: {str(e)}") raise ProviderError(f"API request failed: {str(e)}") except json.JSONDecodeError as json_err: self.logger.error(f"Failed to parse JSON response: {str(json_err)}") - self.logger.error(f"Response content: {response.text}") + self.logger.error(f"Response content: {content_str}") raise ProviderError(f"Invalid JSON response from API: {str(json_err)}") + + def handle_http_error(self, e): + self.logger.error(f"Request failed: {str(e)}") + self.logger.error(f"Response status code: {e.code}") + self.logger.error(f"Response headers: {e.headers}") + content = e.read().decode("utf-8") + self.logger.error(f"Response content: {content}") + if e.code == 403: + error_msg = ( + "Received a 403 Forbidden error. Your session key might be invalid. " + "Please try logging out and logging in again. If the issue persists, " + "you can try using the claude.ai-curl provider as a workaround:\n" + "claudesync api logout\n" + "claudesync api login claude.ai-curl" + ) + self.logger.error(error_msg) + raise ProviderError(error_msg) + raise ProviderError(f"API request failed: {str(e)}") diff --git a/src/claudesync/utils.py b/src/claudesync/utils.py index 76ee98d..5de1230 100644 --- a/src/claudesync/utils.py +++ b/src/claudesync/utils.py @@ -194,10 +194,13 @@ def get_local_files(local_path): # Filter out directories before traversing dirs[:] = [ - d for d in dirs + d + for d in dirs if d not in exclude_dirs and not (gitignore and gitignore.match_file(os.path.join(rel_root, d))) - and not (claudeignore and claudeignore.match_file(os.path.join(rel_root, d))) + and not ( + claudeignore and claudeignore.match_file(os.path.join(rel_root, d)) + ) ] for filename in filenames: diff --git a/tests/providers/test_base_claude_ai.py b/tests/providers/test_base_claude_ai.py index c5765bc..19c76b7 100644 --- a/tests/providers/test_base_claude_ai.py +++ b/tests/providers/test_base_claude_ai.py @@ -1,97 +1,97 @@ -import datetime -import unittest -from unittest.mock import patch, MagicMock, call, ANY -from claudesync.providers.base_claude_ai import BaseClaudeAIProvider - - -class TestBaseClaudeAIProvider(unittest.TestCase): - - def setUp(self): - self.provider = BaseClaudeAIProvider("test_session_key") - - @patch("claudesync.cli.main.ConfigManager") - @patch("claudesync.providers.base_claude_ai.click.echo") - @patch("claudesync.providers.base_claude_ai.click.prompt") - def test_login(self, mock_prompt, mock_echo, mock_config_manager): - mock_prompt.side_effect = ["sk-ant-test123", "Tue, 03 Sep 2099 05:49:08 GMT"] - self.provider.get_organizations = MagicMock( - return_value=[{"id": "org1", "name": "Test Org"}] - ) - mock_config_manager.return_value = MagicMock() - - result = self.provider.login() - - self.assertEqual( - result, ("sk-ant-test123", datetime.datetime(2099, 9, 3, 5, 49, 8)) - ) - self.assertEqual(self.provider.session_key, "sk-ant-test123") - mock_echo.assert_called() - - expected_calls = [ - call("Please enter your sessionKey", type=str), - call( - "Please enter the expires time for the sessionKey", - default=ANY, - type=str, - ), - ] - - # Use assert_has_calls with any_order=True if the order of calls is not guaranteed - mock_prompt.assert_has_calls(expected_calls, any_order=True) - - @patch("claudesync.cli.main.ConfigManager") - @patch("claudesync.providers.base_claude_ai.click.echo") - @patch("claudesync.providers.base_claude_ai.click.prompt") - def test_login_invalid_key(self, mock_prompt, mock_echo, mock_config_manager): - mock_prompt.side_effect = [ - "invalid_key", - "sk-ant-test123", - "Tue, 03 Sep 2099 05:49:08 GMT", - ] - self.provider.get_organizations = MagicMock( - return_value=[{"id": "org1", "name": "Test Org"}] - ) - mock_config_manager.return_value = MagicMock() - - result = self.provider.login() - - self.assertEqual( - result, ("sk-ant-test123", datetime.datetime(2099, 9, 3, 5, 49, 8)) - ) - self.assertEqual(mock_prompt.call_count, 3) - - @patch("claudesync.providers.base_claude_ai.BaseClaudeAIProvider._make_request") - def test_get_organizations(self, mock_make_request): - mock_make_request.return_value = [ - {"uuid": "org1", "name": "Org 1", "capabilities": ["chat", "claude_pro"]}, - {"uuid": "org2", "name": "Org 2", "capabilities": ["chat"]}, - {"uuid": "org3", "name": "Org 3", "capabilities": ["chat", "claude_pro"]}, - ] - - result = self.provider.get_organizations() - - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["id"], "org1") - self.assertEqual(result[1]["id"], "org3") - - @patch("claudesync.providers.base_claude_ai.BaseClaudeAIProvider._make_request") - def test_get_projects(self, mock_make_request): - mock_make_request.return_value = [ - {"uuid": "proj1", "name": "Project 1", "archived_at": None}, - {"uuid": "proj2", "name": "Project 2", "archived_at": "2023-01-01"}, - {"uuid": "proj3", "name": "Project 3", "archived_at": None}, - ] - - result = self.provider.get_projects("org1") - - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["id"], "proj1") - self.assertEqual(result[1]["id"], "proj3") - - def test_make_request_not_implemented(self): - with self.assertRaises(NotImplementedError): - self.provider._make_request("GET", "/test") - - -if __name__ == "__main__": - unittest.main() +import datetime +import unittest +from unittest.mock import patch, MagicMock, call, ANY +from claudesync.providers.base_claude_ai import BaseClaudeAIProvider + + +class TestBaseClaudeAIProvider(unittest.TestCase): + + def setUp(self): + self.provider = BaseClaudeAIProvider("test_session_key") + + @patch("claudesync.cli.main.ConfigManager") + @patch("claudesync.providers.base_claude_ai.click.echo") + @patch("claudesync.providers.base_claude_ai.click.prompt") + def test_login(self, mock_prompt, mock_echo, mock_config_manager): + mock_prompt.side_effect = ["sk-ant-test123", "Tue, 03 Sep 2099 05:49:08 GMT"] + self.provider.get_organizations = MagicMock( + return_value=[{"id": "org1", "name": "Test Org"}] + ) + mock_config_manager.return_value = MagicMock() + + result = self.provider.login() + + self.assertEqual( + result, ("sk-ant-test123", datetime.datetime(2099, 9, 3, 5, 49, 8)) + ) + self.assertEqual(self.provider.session_key, "sk-ant-test123") + mock_echo.assert_called() + + expected_calls = [ + call("Please enter your sessionKey", type=str, hide_input=True), + call( + "Please enter the expires time for the sessionKey (optional)", + default=ANY, + type=str, + ), + ] + + # Use assert_has_calls with any_order=True if the order of calls is not guaranteed + mock_prompt.assert_has_calls(expected_calls, any_order=True) + + @patch("claudesync.cli.main.ConfigManager") + @patch("claudesync.providers.base_claude_ai.click.echo") + @patch("claudesync.providers.base_claude_ai.click.prompt") + def test_login_invalid_key(self, mock_prompt, mock_echo, mock_config_manager): + mock_prompt.side_effect = [ + "invalid_key", + "sk-ant-test123", + "Tue, 03 Sep 2099 05:49:08 GMT", + ] + self.provider.get_organizations = MagicMock( + return_value=[{"id": "org1", "name": "Test Org"}] + ) + mock_config_manager.return_value = MagicMock() + + result = self.provider.login() + + self.assertEqual( + result, ("sk-ant-test123", datetime.datetime(2099, 9, 3, 5, 49, 8)) + ) + self.assertEqual(mock_prompt.call_count, 3) + + @patch("claudesync.providers.base_claude_ai.BaseClaudeAIProvider._make_request") + def test_get_organizations(self, mock_make_request): + mock_make_request.return_value = [ + {"uuid": "org1", "name": "Org 1", "capabilities": ["chat", "claude_pro"]}, + {"uuid": "org2", "name": "Org 2", "capabilities": ["chat"]}, + {"uuid": "org3", "name": "Org 3", "capabilities": ["chat", "claude_pro"]}, + ] + + result = self.provider.get_organizations() + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], "org1") + self.assertEqual(result[1]["id"], "org3") + + @patch("claudesync.providers.base_claude_ai.BaseClaudeAIProvider._make_request") + def test_get_projects(self, mock_make_request): + mock_make_request.return_value = [ + {"uuid": "proj1", "name": "Project 1", "archived_at": None}, + {"uuid": "proj2", "name": "Project 2", "archived_at": "2023-01-01"}, + {"uuid": "proj3", "name": "Project 3", "archived_at": None}, + ] + + result = self.provider.get_projects("org1") + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], "proj1") + self.assertEqual(result[1]["id"], "proj3") + + def test_make_request_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.provider._make_request("GET", "/test") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/providers/test_claude_ai.py b/tests/providers/test_claude_ai.py index 51237cd..d1f5c28 100644 --- a/tests/providers/test_claude_ai.py +++ b/tests/providers/test_claude_ai.py @@ -1,50 +1,83 @@ -import unittest -from unittest.mock import patch, MagicMock -import requests -from claudesync.providers.claude_ai import ClaudeAIProvider -from claudesync.exceptions import ProviderError - - -class TestClaudeAIProvider(unittest.TestCase): - - def setUp(self): - self.provider = ClaudeAIProvider( - "test_session_key", "Tue, 03 Sep 2099 06:51:21 UTC" - ) - self.mock_config = MagicMock() - - @patch("claudesync.config_manager.ConfigManager.get_session_key") - @patch("claudesync.providers.claude_ai.requests.request") - def test_make_request_success(self, mock_request, mock_get_session_key): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"key": "value"} - mock_request.return_value = mock_response - - mock_get_session_key.return_value = "sk-ant-1234" - - result = self.provider._make_request("GET", "/test") - - self.assertEqual(result, {"key": "value"}) - mock_request.assert_called_once() - - @patch("claudesync.providers.claude_ai.requests.request") - def test_make_request_failure(self, mock_request): - mock_request.side_effect = requests.RequestException("Test error") - - with self.assertRaises(ProviderError): - self.provider._make_request("GET", "/test") - - @patch("claudesync.config_manager.ConfigManager.get_session_key") - @patch("claudesync.providers.claude_ai.requests.request") - def test_make_request_403_error(self, mock_request, mock_get_session_key): - mock_response = MagicMock() - mock_response.status_code = 403 - mock_request.return_value = mock_response - - mock_get_session_key.return_value = "sk-ant-1234" - - with self.assertRaises(ProviderError) as context: - self.provider._make_request("GET", "/test") - - self.assertIn("403 Forbidden error", str(context.exception)) +import unittest +from unittest.mock import patch, MagicMock +import urllib.error +import json +from io import BytesIO +import gzip +from claudesync.providers.claude_ai import ClaudeAIProvider +from claudesync.exceptions import ProviderError + + +class TestClaudeAIProvider(unittest.TestCase): + + def setUp(self): + self.provider = ClaudeAIProvider( + "test_session_key", "Tue, 03 Sep 2099 06:51:21 UTC" + ) + self.mock_config = MagicMock() + + @patch("claudesync.config_manager.ConfigManager.get_session_key") + @patch("claudesync.providers.claude_ai.urllib.request.urlopen") + def test_make_request_success(self, mock_urlopen, mock_get_session_key): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.read.return_value = json.dumps({"key": "value"}).encode("utf-8") + mock_urlopen.return_value.__enter__.return_value = mock_response + + mock_get_session_key.return_value = "sk-ant-1234" + + result = self.provider._make_request("GET", "/test") + + self.assertEqual(result, {"key": "value"}) + mock_urlopen.assert_called_once() + + @patch("claudesync.providers.claude_ai.urllib.request.urlopen") + def test_make_request_failure(self, mock_urlopen): + mock_urlopen.side_effect = urllib.error.URLError("Test error") + + with self.assertRaises(ProviderError): + self.provider._make_request("GET", "/test") + + @patch("claudesync.config_manager.ConfigManager.get_session_key") + @patch("claudesync.providers.claude_ai.urllib.request.urlopen") + def test_make_request_403_error(self, mock_urlopen, mock_get_session_key): + mock_response = MagicMock() + mock_response.status = 403 + mock_response.read.return_value = b"Forbidden" + mock_urlopen.side_effect = urllib.error.HTTPError( + url="http://test.com", code=403, msg="Forbidden", hdrs={}, fp=None + ) + + mock_get_session_key.return_value = "sk-ant-1234" + + with self.assertRaises(ProviderError) as context: + self.provider._make_request("GET", "/test") + + self.assertIn("403 Forbidden error", str(context.exception)) + + @patch("claudesync.config_manager.ConfigManager.get_session_key") + @patch("claudesync.providers.claude_ai.urllib.request.urlopen") + def test_make_request_gzip_response(self, mock_urlopen, mock_get_session_key): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.headers = { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + } + + # Create gzipped content + content = json.dumps({"key": "gzipped_value"}).encode("utf-8") + gzipped_content = BytesIO() + with gzip.GzipFile(fileobj=gzipped_content, mode="wb") as gzip_file: + gzip_file.write(content) + + mock_response.read.return_value = gzipped_content.getvalue() + mock_urlopen.return_value.__enter__.return_value = mock_response + + mock_get_session_key.return_value = "sk-ant-1234" + + result = self.provider._make_request("GET", "/test") + + self.assertEqual(result, {"key": "gzipped_value"}) + mock_urlopen.assert_called_once()