Skip to content

Commit 9e6228d

Browse files
authored
Merge pull request #1 from openscilab/add/adapters
Add/adapters
1 parent 9e301f1 commit 9e6228d

11 files changed

Lines changed: 311 additions & 61 deletions

File tree

.coveragerc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
[run]
22
branch = True
33
omit =
4-
*/ipforce/cli.py
5-
*/ipforce/__main__.py
64
*/ipforce/__init__.py
75
[report]
86
# Regexes for lines to exclude from consideration

.github/workflows/test.yml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ jobs:
2424
strategy:
2525
fail-fast: false
2626
matrix:
27-
os: [ubuntu-22.04, windows-2022, macOS-13]
28-
python-version: [3.7, 3.8, 3.9, 3.10.5, 3.11.0, 3.12.0, 3.13.0]
27+
os: [ubuntu-22.04, windows-2022, macos-15-intel]
28+
python-version: [3.7, 3.8, 3.9, 3.10.5, 3.11.0, 3.12.0, 3.13.0, 3.14.0]
2929
steps:
30-
- uses: actions/checkout@v2
30+
- uses: actions/checkout@v5
3131
- name: Set up Python ${{ matrix.python-version }}
32-
uses: actions/setup-python@v2
32+
uses: actions/setup-python@v5
3333
with:
3434
python-version: ${{ matrix.python-version }}
3535
- name: Installation
@@ -42,12 +42,11 @@ jobs:
4242
pip install --upgrade --upgrade-strategy=only-if-needed -r test-requirements.txt
4343
- name: Test with pytest
4444
run: |
45-
python -m pytest . --cov=ipforce --cov-report=term
45+
python -m pytest tests/test_adapters.py --cov=ipforce --cov-report=term
4646
- name: Upload coverage to Codecov
4747
uses: codecov/codecov-action@v4
4848
with:
49-
fail_ci_if_error: true
50-
token: ${{ secrets.CODECOV_TOKEN }}
49+
fail_ci_if_error: false
5150
if: matrix.python-version == env.TEST_PYTHON_VERSION && matrix.os == env.TEST_OS
5251
- name: Version check
5352
run: |

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
55
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
## [0.1] - 2025-xx-xx
89
### Added
9-
### Changed
10+
- Test system
11+
- IPv6TransportAdapter
12+
- IPv4TransportAdapter
1013

11-
[Unreleased]: https://github.com/openscilab/ipforce/compare/TODO...v0.1
14+
[Unreleased]: https://github.com/openscilab/ipforce/compare/v0.1...dev
15+
[0.1]: https://github.com/openscilab/ipforce/compare/7128b04...v0.1

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
<a href="https://discord.gg/TODO"><img src="https://img.shields.io/discord/TODO" alt="Discord Channel"></a>
99
</div>
1010
11-
## Overview
11+
## Overview
1212

1313
<p align="justify">
14-
<b>IPForce</b> is a Python library for TODO.
14+
<b>IPForce</b> is a Python library that provides HTTP adapters for forcing specific IP protocol versions (IPv4 or IPv6) during HTTP requests. It's particularly useful for testing network connectivity, ensuring compatibility with specific network configurations, and controlling which IP protocol version is used for DNS resolution and connections.
1515
</p>
1616

1717
<table>
@@ -55,16 +55,46 @@
5555
- `pip install .`
5656

5757
### PyPI
58-
5958
- Check [Python Packaging User Guide](https://packaging.python.org/installing/)
6059
- `pip install ipforce==0.1`
6160

62-
6361
## Usage
62+
### Enforce IPv4
63+
64+
Use when you need to ensure connections only use IPv4 addresses, useful for legacy systems that don't support IPv6, networks with IPv4-only infrastructure, or testing IPv4 connectivity.
65+
66+
```python
67+
import requests
68+
from ipforce import IPv4TransportAdapter
69+
70+
# Create a session that will only use IPv4 addresses
71+
session = requests.Session()
72+
session.mount('http://', IPv4TransportAdapter())
73+
session.mount('https://', IPv4TransportAdapter())
74+
75+
# All requests through this session will only resolve to IPv4 addresses
76+
response = session.get('https://ifconfig.co/json')
77+
```
78+
79+
### Enforce IPv6
80+
81+
Use when you need to ensure connections only use IPv6 addresses, useful for modern networks with IPv6 infrastructure, testing IPv6 connectivity, or applications requiring IPv6-specific features.
82+
83+
```python
84+
import requests
85+
from ipforce import IPv6TransportAdapter
86+
87+
# Create a session that will only use IPv6 addresses
88+
session = requests.Session()
89+
session.mount('http://', IPv6TransportAdapter())
90+
session.mount('https://', IPv6TransportAdapter())
6491

65-
### Library
92+
# All requests through this session will only resolve to IPv6 addresses
93+
response = session.get('https://ifconfig.co/json')
94+
```
6695

67-
#### Enforce IPv4
96+
> [!WARNING]
97+
> Current adapters are NOT thread-safe! They modify the global `socket.getaddrinfo` function, which can cause issues in multi-threaded applications.
6898
6999
## Issues & Bug Reports
70100

File renamed without changes.

ipforce/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
# -*- coding: utf-8 -*-
12
"""ipforce modules."""
23
from .params import IPFORCE_VERSION
3-
from .adapters import IPv4HTTPAdapter
4+
from .adapters import IPv4TransportAdapter, IPv6TransportAdapter
45

56
__version__ = IPFORCE_VERSION

ipforce/adapters.py

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,65 @@
1+
# -*- coding: utf-8 -*-
2+
"""IPForce Adapters to force IPv4 or IPv6 for requests."""
13
import socket
2-
from typing import List, Tuple
3-
from urllib3 import PoolManager
4+
from typing import Any, List, Tuple
45
from requests.adapters import HTTPAdapter
5-
from requests.sessions import Session
66

7-
class IPv4HTTPAdapter(HTTPAdapter):
7+
8+
class IPv4TransportAdapter(HTTPAdapter):
89
"""A custom HTTPAdapter that enforces the use of IPv4 for DNS resolution during HTTP(S) requests using the requests library."""
910

10-
def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **kwargs: dict) -> None:
11-
"""
12-
Initialize the connection pool manager using a temporary override of socket.getaddrinfo to ensure only IPv4 addresses are used.
13-
This is necessary to ensure that the requests library uses IPv4 addresses for DNS resolution, which is required for some APIs.
14-
:param connections: the number of connection pools to cache
15-
:param maxsize: the maximum number of connections to save in the pool
16-
:param block: whether the connections should block when reaching the max size
17-
:param kwargs: additional keyword arguments for the PoolManager
11+
def send(self, *args: list, **kwargs: dict) -> Any:
1812
"""
19-
self.poolmanager = PoolManager(
20-
num_pools=connections,
21-
maxsize=maxsize,
22-
block=block,
23-
socket_options=self._ipv4_socket_options(),
24-
**kwargs
25-
)
26-
27-
def _ipv4_socket_options(self) -> list:
28-
"""
29-
Temporarily patches socket.getaddrinfo to filter only IPv4 addresses (AF_INET).
13+
Override send method to apply the monkey patch only during the request.
3014
31-
:return: an empty list of socket options; DNS patching occurs here
15+
:param args: additional list arguments for the send method
16+
:param kwargs: additional keyword arguments for the send method
3217
"""
3318
original_getaddrinfo = socket.getaddrinfo
3419

35-
def ipv4_only_getaddrinfo(*args: list, **kwargs: dict) -> List[Tuple]:
36-
results = original_getaddrinfo(*args, **kwargs)
20+
def ipv4_only_getaddrinfo(*gargs: list, **gkwargs: dict) -> List[Tuple]:
21+
"""
22+
Filter getaddrinfo to return only IPv4 addresses.
23+
24+
:param gargs: additional list arguments for the original_getaddrinfo function
25+
:param gkwargs: additional keyword arguments for the original_getaddrinfo function
26+
"""
27+
results = original_getaddrinfo(*gargs, **gkwargs)
3728
return [res for res in results if res[0] == socket.AF_INET]
3829

39-
self._original_getaddrinfo = socket.getaddrinfo
4030
socket.getaddrinfo = ipv4_only_getaddrinfo
31+
try:
32+
response = super().send(*args, **kwargs)
33+
finally:
34+
socket.getaddrinfo = original_getaddrinfo
35+
return response
4136

42-
return []
4337

44-
def __del__(self) -> None:
45-
"""Restores the original socket.getaddrinfo function upon adapter deletion."""
46-
if hasattr(self, "_original_getaddrinfo"):
47-
socket.getaddrinfo = self._original_getaddrinfo
38+
class IPv6TransportAdapter(HTTPAdapter):
39+
"""A custom HTTPAdapter that enforces the use of IPv6 for DNS resolution during HTTP(S) requests using the requests library."""
4840

49-
@staticmethod
50-
def get_ipv4_enforced_session() -> Session:
41+
def send(self, *args: list, **kwargs: dict) -> Any:
5142
"""
52-
Returns a requests.Session with IPv4HTTPAdapter mounted for both HTTP and HTTPS.
53-
All requests made with this session will use IPv4 for DNS resolution.
43+
Override send method to apply the monkey patch only during the request.
5444
55-
:return: requests.Session object with IPv4 enforced
45+
:param args: additional list arguments for the send method
46+
:param kwargs: additional keyword arguments for the send method
5647
"""
57-
session = Session()
58-
adapter = IPv4HTTPAdapter()
59-
session.mount("http://", adapter)
60-
session.mount("https://", adapter)
61-
return session
48+
original_getaddrinfo = socket.getaddrinfo
49+
50+
def ipv6_only_getaddrinfo(*gargs: list, **gkwargs: dict) -> List[Tuple]:
51+
"""
52+
Filter getaddrinfo to return only IPv6 addresses.
53+
54+
:param gargs: additional list arguments for the original_getaddrinfo function
55+
:param gkwargs: additional keyword arguments for the original_getaddrinfo function
56+
"""
57+
results = original_getaddrinfo(*gargs, **gkwargs)
58+
return [res for res in results if res[0] == socket.AF_INET6]
59+
60+
socket.getaddrinfo = ipv6_only_getaddrinfo
61+
try:
62+
response = super().send(*args, **kwargs)
63+
finally:
64+
socket.getaddrinfo = original_getaddrinfo
65+
return response

ipforce/params.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@
44
IPFORCE_VERSION = "0.1"
55
IPFORCE_OVERVIEW = '''OVERVIEW'''
66
IPFORCE_REPO = "https://github.com/openscilab/ipforce"
7-

tests/test_adapters.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import unittest
2+
import socket
3+
from unittest.mock import patch, MagicMock
4+
from ipforce.adapters import IPv4TransportAdapter, IPv6TransportAdapter
5+
6+
7+
class TestIPv4Adapter(unittest.TestCase):
8+
"""Test cases for IPv4TransportAdapter."""
9+
10+
def setup(self):
11+
"""Set up test fixtures."""
12+
self.adapter = IPv4TransportAdapter()
13+
14+
def test_ipv4_filtering_during_send(self):
15+
"""Test that IPv4 adapter filters only IPv4 addresses during send."""
16+
self.setup()
17+
mock_results = [
18+
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.1.1', 80)), # IPv4
19+
(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 80)), # IPv6
20+
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.1', 80)), # IPv4
21+
]
22+
23+
original_getaddrinfo = socket.getaddrinfo
24+
captured_results = []
25+
26+
def mock_super_send(*args, **kwargs):
27+
# Capture the filtered results during send
28+
captured_results.extend(socket.getaddrinfo('example.com', 80))
29+
return MagicMock()
30+
31+
with patch('socket.getaddrinfo', return_value=mock_results):
32+
with patch.object(IPv4TransportAdapter.__bases__[0], 'send', mock_super_send):
33+
self.adapter.send(MagicMock())
34+
35+
# Only IPv4 results should be captured
36+
self.assertEqual(len(captured_results), 2)
37+
for result in captured_results:
38+
self.assertEqual(result[0], socket.AF_INET)
39+
40+
def test_cleanup_after_send(self):
41+
"""Test that the adapter properly restores original getaddrinfo after send."""
42+
self.setup()
43+
original_getaddrinfo = socket.getaddrinfo
44+
45+
with patch.object(IPv4TransportAdapter.__bases__[0], 'send', return_value=MagicMock()):
46+
self.adapter.send(MagicMock())
47+
48+
# Verify it was restored after send
49+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)
50+
51+
def test_cleanup_on_exception(self):
52+
"""Test that the adapter restores original getaddrinfo even if send raises."""
53+
self.setup()
54+
original_getaddrinfo = socket.getaddrinfo
55+
56+
with patch.object(IPv4TransportAdapter.__bases__[0], 'send', side_effect=Exception("Test error")):
57+
with self.assertRaises(Exception):
58+
self.adapter.send(MagicMock())
59+
60+
# Verify it was restored even after exception
61+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)
62+
63+
64+
class TestIPv6Adapter(unittest.TestCase):
65+
"""Test cases for IPv6TransportAdapter."""
66+
67+
def setup(self):
68+
"""Set up test fixtures."""
69+
self.adapter = IPv6TransportAdapter()
70+
71+
def test_ipv6_filtering_during_send(self):
72+
"""Test that IPv6 adapter filters only IPv6 addresses during send."""
73+
self.setup()
74+
mock_results = [
75+
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.1.1', 80)), # IPv4
76+
(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 80)), # IPv6
77+
(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('2001:db8::1', 80)), # IPv6
78+
]
79+
80+
captured_results = []
81+
82+
def mock_super_send(*args, **kwargs):
83+
# Capture the filtered results during send
84+
captured_results.extend(socket.getaddrinfo('example.com', 80))
85+
return MagicMock()
86+
87+
with patch('socket.getaddrinfo', return_value=mock_results):
88+
with patch.object(IPv6TransportAdapter.__bases__[0], 'send', mock_super_send):
89+
self.adapter.send(MagicMock())
90+
91+
# Only IPv6 results should be captured
92+
self.assertEqual(len(captured_results), 2)
93+
for result in captured_results:
94+
self.assertEqual(result[0], socket.AF_INET6)
95+
96+
def test_cleanup_after_send(self):
97+
"""Test that the adapter properly restores original getaddrinfo after send."""
98+
self.setup()
99+
original_getaddrinfo = socket.getaddrinfo
100+
101+
with patch.object(IPv6TransportAdapter.__bases__[0], 'send', return_value=MagicMock()):
102+
self.adapter.send(MagicMock())
103+
104+
# Verify it was restored after send
105+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)
106+
107+
def test_cleanup_on_exception(self):
108+
"""Test that the adapter restores original getaddrinfo even if send raises."""
109+
self.setup()
110+
original_getaddrinfo = socket.getaddrinfo
111+
112+
with patch.object(IPv6TransportAdapter.__bases__[0], 'send', side_effect=Exception("Test error")):
113+
with self.assertRaises(Exception):
114+
self.adapter.send(MagicMock())
115+
116+
# Verify it was restored even after exception
117+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)
118+
119+
120+
class TestAdapterIntegration(unittest.TestCase):
121+
"""Integration tests for both adapters."""
122+
123+
def test_both_adapters_independent(self):
124+
"""Test that both adapters can coexist without interference."""
125+
ipv4_adapter = IPv4TransportAdapter()
126+
ipv6_adapter = IPv6TransportAdapter()
127+
original_getaddrinfo = socket.getaddrinfo
128+
129+
with patch.object(IPv4TransportAdapter.__bases__[0], 'send', return_value=MagicMock()):
130+
ipv4_adapter.send(MagicMock())
131+
132+
with patch.object(IPv6TransportAdapter.__bases__[0], 'send', return_value=MagicMock()):
133+
ipv6_adapter.send(MagicMock())
134+
135+
# Verify original is still intact
136+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)
137+
138+
def test_sequential_sends_restore_correctly(self):
139+
"""Test that multiple sequential sends properly restore getaddrinfo."""
140+
adapter = IPv4TransportAdapter()
141+
original_getaddrinfo = socket.getaddrinfo
142+
143+
with patch.object(IPv4TransportAdapter.__bases__[0], 'send', return_value=MagicMock()):
144+
adapter.send(MagicMock())
145+
adapter.send(MagicMock())
146+
adapter.send(MagicMock())
147+
148+
# Verify original is still intact after multiple sends
149+
self.assertEqual(socket.getaddrinfo, original_getaddrinfo)

0 commit comments

Comments
 (0)