Skip to content

Commit 80e7b8c

Browse files
committed
feat(http): replace requests dependency with custom HTTP implementation
- Created new django_tailwind_cli.utils.http module with custom HTTP functions - Replaced RequestException with RequestError, ConnectionError with NetworkConnectionError - Added proper type annotations throughout codebase - Updated all test files with proper mock_download function signatures - Removed requests dependency from pyproject.toml - Added Django 6.0 support to version matrix - Fixed exception naming to follow Python conventions and avoid builtin shadowing
1 parent e888b00 commit 80e7b8c

File tree

15 files changed

+551
-348
lines changed

15 files changed

+551
-348
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
### 🔧 Technical Improvements
66
- **Type safety**: Fixed all pyright typing errors for better code quality and maintainability
77
- **Code cleanup**: Removed unused functions and improved type annotations throughout codebase
8-
- **Dependencies**: Removed unused mypy configuration and django-stubs dependency
8+
- **Dependencies**: Removed requests dependency, replaced with custom HTTP implementation
9+
- **Exception handling**: Fixed exception naming to follow Python conventions and avoid builtin shadowing
10+
- **Test coverage**: Added proper type annotations to all mock_download functions across test files
911
- **VS Code integration**: Added PyLance ignore comments for test files accessing private methods
12+
- **Django 6.0 support**: Added Django 6.0 to testing matrix and version compatibility
1013

1114
## 4.3.0 (2025-07-12)
1215

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Key Django settings for configuration:
114114
## Version Support
115115

116116
- Python: 3.10-3.14
117-
- Django: 4.0-5.2
117+
- Django: 4.0-6.0
118118
- Tailwind CSS: 4.x only (use v2.21.1 for Tailwind 3.x)
119119

120120
## Commit Message Guidelines

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Start adding Tailwind classes to your templates:
160160
## 📋 Requirements
161161

162162
- **Python:** 3.10+
163-
- **Django:** 4.0+
163+
- **Django:** 4.0-6.0+
164164
- **Platform:** Windows, macOS, Linux (automatic platform detection)
165165

166166
## ⚙️ Configuration Examples

pyproject.toml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,11 @@ classifiers = [
2424
"Framework :: Django :: 5.0",
2525
"Framework :: Django :: 5.1",
2626
"Framework :: Django :: 5.2",
27+
"Framework :: Django :: 6.0",
2728
]
2829
dynamic = ["version"]
2930
requires-python = ">=3.10"
30-
dependencies = [
31-
"django>=4.0",
32-
"django-typer>=2.1.2",
33-
"requests>=2.32.3",
34-
"semver>=3.0.4",
35-
]
31+
dependencies = ["django>=4.0", "django-typer>=2.1.2", "semver>=3.0.4"]
3632

3733
[project.optional-dependencies]
3834
django-extensions = ["django-extensions>=3.2", "werkzeug>=3.0"]

src/django_tailwind_cli/config.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
from pathlib import Path
7979
from typing import NamedTuple
8080

81-
import requests
81+
from django_tailwind_cli.utils import http
8282
from django.conf import settings
8383
from semver import Version
8484

@@ -288,13 +288,15 @@ def get_version() -> tuple[str, Version]:
288288
# Fetch latest version from GitHub
289289
timeout = getattr(settings, "TAILWIND_CLI_REQUEST_TIMEOUT", 10)
290290
try:
291-
r = requests.get(f"https://github.com/{repo_url}/releases/latest/", timeout=timeout, allow_redirects=False)
292-
if r.ok and "location" in r.headers:
293-
version_str = r.headers["location"].rstrip("/").split("/")[-1].replace("v", "")
291+
success, location = http.fetch_redirect_location(
292+
f"https://github.com/{repo_url}/releases/latest/", timeout=timeout
293+
)
294+
if success and location:
295+
version_str = location.rstrip("/").split("/")[-1].replace("v", "")
294296
# Cache the result
295297
_save_cached_version(repo_url, version_str)
296298
return version_str, Version.parse(version_str)
297-
except (requests.RequestException, ValueError):
299+
except (http.RequestError, ValueError):
298300
# Network or parsing error, fall back to cached or default
299301
pass
300302

src/django_tailwind_cli/management/commands/tailwind.py

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any
1313
from collections.abc import Callable
1414

15-
import requests
15+
from django_tailwind_cli.utils import http
1616
import typer
1717
from django.conf import settings
1818
from django.core.management.base import CommandError
@@ -1284,38 +1284,21 @@ def _download_cli_with_progress(url: str, filepath: Path) -> None:
12841284
url: Download URL.
12851285
filepath: Destination file path.
12861286
"""
1287-
try:
1288-
response = requests.get(url, stream=True, timeout=30)
1289-
response.raise_for_status()
1290-
1291-
total_size = int(response.headers.get("content-length", 0))
1292-
1293-
if total_size == 0:
1294-
# Fallback for unknown size
1295-
typer.secho("Downloading Tailwind CSS CLI...", fg=typer.colors.YELLOW)
1296-
filepath.write_bytes(response.content)
1297-
return
1287+
last_progress = 0
12981288

1299-
# Download with progress
1300-
downloaded = 0
1301-
filepath.parent.mkdir(parents=True, exist_ok=True)
1302-
1303-
with filepath.open("wb") as f:
1304-
typer.secho("Downloading Tailwind CSS CLI...", fg=typer.colors.YELLOW)
1305-
for chunk in response.iter_content(chunk_size=8192):
1306-
if chunk:
1307-
f.write(chunk)
1308-
downloaded += len(chunk)
1309-
1310-
# Show progress every 10%
1311-
if total_size > 0:
1312-
progress = (downloaded / total_size) * 100
1313-
if downloaded % (total_size // 10 + 1) < 8192:
1314-
typer.secho(f"Progress: {progress:.1f}%", fg=typer.colors.CYAN)
1289+
def progress_callback(downloaded: int, total_size: int, progress: float) -> None:
1290+
nonlocal last_progress
1291+
# Show progress every 10%
1292+
if total_size > 0 and int(progress / 10) > int(last_progress / 10):
1293+
typer.secho(f"Progress: {progress:.1f}% ({downloaded}/{total_size} bytes)", fg=typer.colors.CYAN)
1294+
last_progress = progress
13151295

1296+
try:
1297+
typer.secho("Downloading Tailwind CSS CLI...", fg=typer.colors.YELLOW)
1298+
http.download_with_progress(url, filepath, timeout=30, progress_callback=progress_callback)
13161299
typer.secho("Download completed!", fg=typer.colors.GREEN)
13171300

1318-
except requests.RequestException as e:
1301+
except http.RequestError as e:
13191302
raise CommandError(f"Failed to download Tailwind CSS CLI: {e}") from e
13201303

13211304

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Utilities for django-tailwind-cli."""
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""HTTP utilities using urllib instead of requests."""
2+
3+
from __future__ import annotations
4+
5+
import socket
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
from collections.abc import Callable
9+
from urllib.error import HTTPError as UrllibHTTPError
10+
from urllib.error import URLError
11+
from urllib.request import Request, urlopen
12+
13+
if TYPE_CHECKING:
14+
pass
15+
16+
17+
class RequestError(Exception):
18+
"""Base exception for HTTP requests."""
19+
20+
21+
class HTTPError(RequestError):
22+
"""HTTP status error."""
23+
24+
25+
class NetworkConnectionError(RequestError):
26+
"""Network connection error."""
27+
28+
29+
class RequestTimeoutError(RequestError):
30+
"""Request timeout error."""
31+
32+
33+
def fetch_redirect_location(url: str, timeout: int = 10) -> tuple[bool, str | None]:
34+
"""Fetch redirect location from a URL.
35+
36+
Args:
37+
url: URL to fetch from
38+
timeout: Request timeout in seconds
39+
40+
Returns:
41+
Tuple of (success, location_header)
42+
43+
Raises:
44+
RequestError: On network or HTTP errors
45+
"""
46+
try:
47+
req = Request(url)
48+
# Set User-Agent to avoid blocking
49+
req.add_header("User-Agent", "django-tailwind-cli")
50+
51+
with urlopen(req, timeout=timeout) as response:
52+
# Check if it's a redirect status
53+
if response.getcode() in (301, 302, 303, 307, 308):
54+
location = response.headers.get("Location")
55+
return True, location
56+
elif response.getcode() == 200:
57+
return True, None
58+
else:
59+
return False, None
60+
61+
except UrllibHTTPError as e:
62+
# Handle redirect responses that urllib might treat as errors
63+
if e.code in (301, 302, 303, 307, 308):
64+
location = e.headers.get("Location")
65+
return True, location
66+
return False, None
67+
except URLError as e:
68+
if isinstance(e.reason, socket.timeout):
69+
raise RequestTimeoutError(f"Request timeout: {e}") from e
70+
elif isinstance(e.reason, (ConnectionRefusedError, socket.gaierror)):
71+
raise NetworkConnectionError(f"Connection error: {e}") from e
72+
else:
73+
raise RequestError(f"URL error: {e}") from e
74+
except TimeoutError as e:
75+
raise RequestTimeoutError(f"Socket timeout: {e}") from e
76+
except Exception as e:
77+
raise RequestError(f"Unexpected error: {e}") from e
78+
79+
80+
def download_with_progress(
81+
url: str, filepath: Path, timeout: int = 30, progress_callback: Callable[[int, int, float], None] | None = None
82+
) -> None:
83+
"""Download a file with progress indication.
84+
85+
Args:
86+
url: Download URL
87+
filepath: Destination file path
88+
timeout: Request timeout in seconds
89+
progress_callback: Optional callback for progress updates
90+
91+
Raises:
92+
RequestError: On network or HTTP errors
93+
"""
94+
try:
95+
req = Request(url)
96+
req.add_header("User-Agent", "django-tailwind-cli")
97+
98+
with urlopen(req, timeout=timeout) as response:
99+
# Check for HTTP errors
100+
if response.getcode() >= 400:
101+
raise HTTPError(f"HTTP {response.getcode()}: {response.reason}")
102+
103+
# Get content length for progress tracking
104+
content_length_header = response.headers.get("Content-Length")
105+
total_size = int(content_length_header) if content_length_header else 0
106+
107+
# Ensure parent directory exists
108+
filepath.parent.mkdir(parents=True, exist_ok=True)
109+
110+
downloaded = 0
111+
chunk_size = 8192
112+
113+
with filepath.open("wb") as f:
114+
while True:
115+
chunk = response.read(chunk_size)
116+
if not chunk:
117+
break
118+
119+
f.write(chunk)
120+
downloaded += len(chunk)
121+
122+
# Call progress callback if provided
123+
if progress_callback and total_size > 0:
124+
progress = (downloaded / total_size) * 100
125+
progress_callback(downloaded, total_size, progress)
126+
127+
except UrllibHTTPError as e:
128+
raise HTTPError(f"HTTP {e.code}: {e.reason}") from e
129+
except URLError as e:
130+
if isinstance(e.reason, socket.timeout):
131+
raise RequestTimeoutError(f"Download timeout: {e}") from e
132+
elif isinstance(e.reason, (ConnectionRefusedError, socket.gaierror)):
133+
raise NetworkConnectionError(f"Connection error: {e}") from e
134+
else:
135+
raise RequestError(f"URL error: {e}") from e
136+
except TimeoutError as e:
137+
raise RequestTimeoutError(f"Download timeout: {e}") from e
138+
except OSError as e:
139+
raise RequestError(f"File error: {e}") from e
140+
except Exception as e:
141+
raise RequestError(f"Unexpected error: {e}") from e
142+
143+
144+
def get_content_sync(url: str, timeout: int = 30) -> bytes:
145+
"""Get content from URL synchronously.
146+
147+
Args:
148+
url: URL to fetch from
149+
timeout: Request timeout in seconds
150+
151+
Returns:
152+
Response content as bytes
153+
154+
Raises:
155+
RequestError: On network or HTTP errors
156+
"""
157+
try:
158+
req = Request(url)
159+
req.add_header("User-Agent", "django-tailwind-cli")
160+
161+
with urlopen(req, timeout=timeout) as response:
162+
if response.getcode() >= 400:
163+
raise HTTPError(f"HTTP {response.getcode()}: {response.reason}")
164+
return response.read()
165+
166+
except UrllibHTTPError as e:
167+
raise HTTPError(f"HTTP {e.code}: {e.reason}") from e
168+
except URLError as e:
169+
if isinstance(e.reason, socket.timeout):
170+
raise RequestTimeoutError(f"Request timeout: {e}") from e
171+
elif isinstance(e.reason, (ConnectionRefusedError, socket.gaierror)):
172+
raise NetworkConnectionError(f"Connection error: {e}") from e
173+
else:
174+
raise RequestError(f"URL error: {e}") from e
175+
except TimeoutError as e:
176+
raise RequestTimeoutError(f"Request timeout: {e}") from e
177+
except Exception as e:
178+
raise RequestError(f"Unexpected error: {e}") from e

tests/test_additional_commands.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from pathlib import Path
9+
from collections.abc import Callable
910

1011
import pytest
1112
from django.conf import LazySettings
@@ -30,7 +31,14 @@ def configure_test_settings(settings: LazySettings, tmp_path: Path, mocker: Mock
3031

3132
# Mock subprocess to avoid actual CLI calls
3233
mocker.patch("subprocess.run")
33-
mocker.patch("requests.get").return_value.content = b"fake binary content"
34+
35+
def mock_download(
36+
url: str, filepath: Path, timeout: int = 30, progress_callback: Callable[[int, int, float], None] | None = None
37+
) -> None:
38+
filepath.parent.mkdir(parents=True, exist_ok=True)
39+
filepath.write_bytes(b"fake binary content")
40+
41+
mocker.patch("django_tailwind_cli.utils.http.download_with_progress", side_effect=mock_download)
3442

3543

3644
class TestConfigCommand:

tests/test_config.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ def configure_settings(
1515
):
1616
settings.BASE_DIR = Path("/home/user/project")
1717
settings.STATICFILES_DIRS = (settings.BASE_DIR / "assets",)
18-
request_get = mocker.patch("requests.get")
19-
request_get.return_value.headers = {"location": "https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.1.3"}
18+
request_get = mocker.patch("django_tailwind_cli.utils.http.fetch_redirect_location")
19+
request_get.return_value = (True, "https://github.com/tailwindlabs/tailwindcss/releases/tag/v4.1.3")
2020

2121

2222
@pytest.mark.parametrize(
@@ -45,8 +45,8 @@ def test_get_version(
4545
cache_path.unlink()
4646

4747
# Mock failed network request to force fallback
48-
request_get = mocker.patch("requests.get")
49-
request_get.return_value.ok = False
48+
request_get = mocker.patch("django_tailwind_cli.utils.http.fetch_redirect_location")
49+
request_get.return_value = (False, None)
5050

5151
r_version_str, r_version = get_version()
5252
assert r_version_str == expected_version_str
@@ -63,8 +63,8 @@ def test_get_version_latest_without_proper_http_response(mocker: MockerFixture):
6363
if cache_path.exists():
6464
cache_path.unlink()
6565

66-
request_get = mocker.patch("requests.get")
67-
request_get.return_value.ok = False
66+
request_get = mocker.patch("django_tailwind_cli.utils.http.fetch_redirect_location")
67+
request_get.return_value = (False, None)
6868

6969
r_version_str, r_version = get_version()
7070
assert r_version_str == "4.1.3"
@@ -81,8 +81,8 @@ def test_get_version_latest_without_redirect(mocker: MockerFixture):
8181
if cache_path.exists():
8282
cache_path.unlink()
8383

84-
request_get = mocker.patch("requests.get")
85-
request_get.return_value.headers = {}
84+
request_get = mocker.patch("django_tailwind_cli.utils.http.fetch_redirect_location")
85+
request_get.return_value = (True, None)
8686

8787
r_version_str, r_version = get_version()
8888
assert r_version_str == "4.1.3"
@@ -297,10 +297,8 @@ def test_daisy_ui_support(
297297
mocker: MockerFixture,
298298
):
299299
settings.TAILWIND_CLI_USE_DAISY_UI = True
300-
request_get = mocker.patch("requests.get")
301-
request_get.return_value.headers = {
302-
"location": "https://github.com/dobicinaitis/tailwind-cli-extra/releases/tag/v2.1.4"
303-
}
300+
request_get = mocker.patch("django_tailwind_cli.utils.http.fetch_redirect_location")
301+
request_get.return_value = (True, "https://github.com/dobicinaitis/tailwind-cli-extra/releases/tag/v2.1.4")
304302

305303
c = get_config()
306304

0 commit comments

Comments
 (0)