Skip to content

Commit e28ecb5

Browse files
authored
Enhancing user-facing API (#21)
* expose factory classes * add user facing enums * add deprecation warning to direct use of global adapters * add user facing API * add tests for user-interfacing API * `CHANGELOG.md` updated * `README.md` updated * add pytest config file * `CHANGELOG.md` updated * `README.md` updated * update docstrings * docstring updated * Update docstring to remove ValueError mention Removed unnecessary ValueError raise documentation. * remove enum.py and put it in params.py * remove `Roadmap` section
1 parent fdbfb0a commit e28ecb5

8 files changed

Lines changed: 241 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased]
88
### Added
9+
- `IPForceAdapter` unified factory function
10+
- `IPForceSession` unified session class
11+
- `IPVersion` enum (`V4`, `V6`)
12+
- `IPForceMethod` enum (`GLOBAL`, `LOCK`)
913
- `IPv6LockAdapter` class
1014
- `IPv4LockAdapter` class
1115
- Logo

README.md

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<table>
1919
<tr>
2020
<td align="center">PyPI Counter</td>
21-
<td align="center"><a href="http://pepy.tech/project/ipforce"><img src="http://pepy.tech/badge/ipforce"></a></td>
21+
<td align="center"><a href="http://pepy.tech/project/ipforce"><img src="https://static.pepy.tech/personalized-badge/ipforce?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BLUE&left_text=downloads"></a></td>
2222
</tr>
2323
<tr>
2424
<td align="center">Github Stars</td>
@@ -43,7 +43,7 @@
4343
<table>
4444
<tr>
4545
<td align="center">Code Quality</td>
46-
<td align="center"><a href="https://app.codacy.com/gh/openscilab/ipforce/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade"><img src="https://app.codacy.com/project/badge/Grade/cb2ab6584eb443b8a33da4d4252480bc"/></a></td>
46+
<td align="center"><a href="https://app.codacy.com/gh/openscilab/ipforce/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade"><img src="https://app.codacy.com/project/badge/Grade/16eb5d38472c4645b012e8f8f14c8442"/></a></td>
4747
<td align="center"><a href="https://www.codefactor.io/repository/github/openscilab/ipforce"><img src="https://www.codefactor.io/repository/github/openscilab/ipforce/badge" alt="CodeFactor"></a></td>
4848
</tr>
4949
</table>
@@ -60,55 +60,66 @@
6060
- `pip install ipforce==0.1`
6161

6262
## Usage
63-
### Enforce IPv4
6463

64+
### Enforce IPv4
6565
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.
6666

6767
```python
68+
from ipforce import IPForceAdapter, IPVersion, IPForceMethod
6869
import requests
69-
from ipforce import IPv4TransportAdapter
7070

71-
# Create a session that will only use IPv4 addresses
7271
session = requests.Session()
73-
session.mount('http://', IPv4TransportAdapter())
74-
session.mount('https://', IPv4TransportAdapter())
72+
adapter = IPForceAdapter(IPVersion.V4, IPForceMethod.LOCK)
73+
session.mount('http://', adapter)
74+
session.mount('https://', adapter)
7575

76-
# All requests through this session will only resolve to IPv4 addresses
7776
response = session.get('https://ifconfig.co/json')
7877
```
7978

8079
### Enforce IPv6
81-
8280
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.
8381

8482
```python
83+
from ipforce import IPForceAdapter, IPVersion, IPForceMethod
8584
import requests
86-
from ipforce import IPv6TransportAdapter
8785

88-
# Create a session that will only use IPv6 addresses
8986
session = requests.Session()
90-
session.mount('http://', IPv6TransportAdapter())
91-
session.mount('https://', IPv6TransportAdapter())
87+
adapter = IPForceAdapter(IPVersion.V6, IPForceMethod.LOCK)
88+
session.mount('http://', adapter)
89+
session.mount('https://', adapter)
9290

93-
# All requests through this session will only resolve to IPv6 addresses
9491
response = session.get('https://ifconfig.co/json')
9592
```
9693

94+
### Using IPForceSession
95+
96+
```python
97+
from ipforce import IPForceSession, IPVersion
98+
99+
with IPForceSession(IPVersion.V4) as session:
100+
response = session.get('https://ifconfig.co/json')
101+
```
102+
103+
### Available Methods
104+
105+
| Method | Description |
106+
|--------|-------------|
107+
| `IPForceMethod.LOCK` | Thread-safe — global lock serialization (default) |
108+
| `IPForceMethod.GLOBAL` | Non-thread-safe — temporary getaddrinfo patch |
109+
97110
> [!WARNING]
98-
> `IPv4TransportAdapter` / `IPv6TransportAdapter` are NOT thread-safe. They modify the global `socket.getaddrinfo` function, which can cause race conditions in multi-threaded applications. Use the thread-safe adapters below for concurrent usage.
111+
> `IPForceMethod.GLOBAL` is NOT thread-safe. It modifies the global `socket.getaddrinfo` function, which can cause race conditions in multi-threaded applications. Use `IPForceMethod.LOCK` (the default) for concurrent usage.
99112
100-
### Thread-Safe: Lock-Based Adapters
113+
### Direct Class Usage (Deprecated)
101114

102-
A process-wide lock serializes access to `socket.getaddrinfo`, guaranteeing correctness under concurrent access.
115+
The following direct class usage still works but is deprecated in favor of the unified API above:
103116

104117
```python
105-
import requests
106118
from ipforce import IPv4LockAdapter, IPv6LockAdapter
107119

108120
session = requests.Session()
109121
session.mount('http://', IPv4LockAdapter()) # or IPv6LockAdapter()
110122
session.mount('https://', IPv4LockAdapter()) # or IPv6LockAdapter()
111-
112123
response = session.get('https://ifconfig.co/json')
113124
```
114125

ipforce/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# -*- coding: utf-8 -*-
22
"""ipforce modules."""
3-
from .params import IPFORCE_VERSION
3+
from .params import IPFORCE_VERSION, IPVersion, IPForceMethod
4+
from .api import IPForceAdapter, IPForceSession
45
from .adapters import IPv4TransportAdapter, IPv6TransportAdapter
56
from .adapters import IPv4LockAdapter, IPv6LockAdapter
67

78
__version__ = IPFORCE_VERSION
89

910
__all__ = [
11+
"IPVersion", "IPForceMethod",
12+
"IPForceAdapter", "IPForceSession",
1013
"IPv4TransportAdapter", "IPv6TransportAdapter",
1114
"IPv4LockAdapter", "IPv6LockAdapter",
1215
]

ipforce/adapters.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
"""IPForce Adapters to force IPv4 or IPv6 for requests."""
33
import socket
4+
import warnings
45
from typing import Any, List, Tuple
56
from requests.adapters import HTTPAdapter
67
from threading import Lock
@@ -13,6 +14,15 @@
1314
class IPv4TransportAdapter(HTTPAdapter):
1415
"""A custom HTTPAdapter that enforces the use of IPv4 for DNS resolution during HTTP(S) requests using the requests library."""
1516

17+
def __init__(self, *args, **kwargs) -> None:
18+
"""Initialize the adapter and emit a deprecation warning."""
19+
warnings.warn(
20+
"IPv4TransportAdapter is deprecated, use IPForceAdapter(IPVersion.V4, IPForceMethod.GLOBAL) instead",
21+
DeprecationWarning,
22+
stacklevel=2,
23+
)
24+
super().__init__(*args, **kwargs)
25+
1626
def send(self, *args: list, **kwargs: dict) -> Any:
1727
"""
1828
Override send method to apply the monkey patch only during the request.
@@ -43,6 +53,15 @@ def ipv4_only_getaddrinfo(*gargs: list, **gkwargs: dict) -> List[Tuple]:
4353
class IPv6TransportAdapter(HTTPAdapter):
4454
"""A custom HTTPAdapter that enforces the use of IPv6 for DNS resolution during HTTP(S) requests using the requests library."""
4555

56+
def __init__(self, *args, **kwargs) -> None:
57+
"""Initialize the adapter and emit a deprecation warning."""
58+
warnings.warn(
59+
"IPv6TransportAdapter is deprecated, use IPForceAdapter(IPVersion.V6, IPForceMethod.GLOBAL) instead",
60+
DeprecationWarning,
61+
stacklevel=2,
62+
)
63+
super().__init__(*args, **kwargs)
64+
4665
def send(self, *args: list, **kwargs: dict) -> Any:
4766
"""
4867
Override send method to apply the monkey patch only during the request.

ipforce/api.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# -*- coding: utf-8 -*-
2+
"""Unified public API for IPForce adapter and session creation."""
3+
import warnings
4+
5+
from requests import Session
6+
from requests.adapters import HTTPAdapter
7+
8+
from .params import IPVersion, IPForceMethod
9+
from .adapters import (
10+
IPv4TransportAdapter, IPv6TransportAdapter,
11+
IPv4LockAdapter, IPv6LockAdapter,
12+
)
13+
14+
_ADAPTER_REGISTRY = {
15+
(IPVersion.V4, IPForceMethod.GLOBAL): IPv4TransportAdapter,
16+
(IPVersion.V6, IPForceMethod.GLOBAL): IPv6TransportAdapter,
17+
(IPVersion.V4, IPForceMethod.LOCK): IPv4LockAdapter,
18+
(IPVersion.V6, IPForceMethod.LOCK): IPv6LockAdapter,
19+
}
20+
21+
22+
def IPForceAdapter(
23+
ip_version: IPVersion,
24+
method: IPForceMethod = IPForceMethod.LOCK,
25+
) -> HTTPAdapter:
26+
"""
27+
Create an HTTP adapter that forces a specific IP version.
28+
29+
:param ip_version: IPVersion.V4 or IPVersion.V6
30+
:param method: thread-safety strategy (default: LOCK)
31+
:return: configured HTTPAdapter instance
32+
"""
33+
adapter_cls = _ADAPTER_REGISTRY.get((ip_version, method))
34+
if adapter_cls is None:
35+
raise ValueError("Unsupported combination: {v} + {m}".format(v=ip_version, m=method))
36+
with warnings.catch_warnings():
37+
warnings.simplefilter("ignore", DeprecationWarning)
38+
return adapter_cls()
39+
40+
41+
class IPForceSession(Session):
42+
"""A requests.Session pre-configured to force a specific IP version."""
43+
44+
def __init__(
45+
self,
46+
ip_version: IPVersion,
47+
method: IPForceMethod = IPForceMethod.LOCK,
48+
) -> None:
49+
"""
50+
Initialize the session with an IP-version-forced adapter.
51+
52+
:param ip_version: IPVersion.V4 or IPVersion.V6
53+
:param method: thread-safety strategy (default: LOCK)
54+
"""
55+
super().__init__()
56+
adapter = IPForceAdapter(ip_version, method)
57+
self.mount('http://', adapter)
58+
self.mount('https://', adapter)

ipforce/params.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
# -*- coding: utf-8 -*-
22
"""ipforce params."""
3+
from enum import Enum
34

45
IPFORCE_VERSION = "0.1"
56
IPFORCE_OVERVIEW = '''OVERVIEW'''
67
IPFORCE_REPO = "https://github.com/openscilab/ipforce"
8+
9+
10+
class IPVersion(Enum):
11+
"""IP protocol version to enforce for DNS resolution."""
12+
13+
V4 = "ipv4"
14+
V6 = "ipv6"
15+
16+
17+
class IPForceMethod(Enum):
18+
"""Thread-safety strategy for address family enforcement."""
19+
20+
GLOBAL = "global"
21+
LOCK = "lock"

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
filterwarnings =
3+
ignore:.*is deprecated, use IP.*:DeprecationWarning

tests/test_api.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for the unified IPForceAdapter / IPForceSession API."""
2+
import socket
3+
import warnings
4+
import unittest
5+
6+
from requests.adapters import HTTPAdapter
7+
8+
from ipforce import (
9+
IPVersion, IPForceMethod,
10+
IPForceAdapter, IPForceSession,
11+
IPv4TransportAdapter, IPv6TransportAdapter,
12+
)
13+
from ipforce.adapters import _BaseLockAdapter
14+
15+
16+
class TestIPForceAdapterFactory(unittest.TestCase):
17+
"""Test that IPForceAdapter returns correct adapter types."""
18+
19+
def test_v4_lock(self):
20+
adapter = IPForceAdapter(IPVersion.V4, IPForceMethod.LOCK)
21+
self.assertIsInstance(adapter, _BaseLockAdapter)
22+
self.assertEqual(adapter._family, socket.AF_INET)
23+
24+
def test_v6_lock(self):
25+
adapter = IPForceAdapter(IPVersion.V6, IPForceMethod.LOCK)
26+
self.assertIsInstance(adapter, _BaseLockAdapter)
27+
self.assertEqual(adapter._family, socket.AF_INET6)
28+
29+
def test_v4_global(self):
30+
adapter = IPForceAdapter(IPVersion.V4, IPForceMethod.GLOBAL)
31+
self.assertIsInstance(adapter, HTTPAdapter)
32+
33+
def test_v6_global(self):
34+
adapter = IPForceAdapter(IPVersion.V6, IPForceMethod.GLOBAL)
35+
self.assertIsInstance(adapter, HTTPAdapter)
36+
37+
def test_default_method_is_lock(self):
38+
adapter = IPForceAdapter(IPVersion.V4)
39+
self.assertIsInstance(adapter, _BaseLockAdapter)
40+
41+
def test_invalid_combination_raises(self):
42+
with self.assertRaises((ValueError, KeyError)):
43+
IPForceAdapter(IPVersion.V4, "not_a_method")
44+
45+
46+
class TestIPForceSession(unittest.TestCase):
47+
"""Test IPForceSession class."""
48+
49+
def test_v4_session_mounts_lock_adapter(self):
50+
with IPForceSession(IPVersion.V4) as session:
51+
adapter = session.get_adapter('https://example.com')
52+
self.assertIsInstance(adapter, _BaseLockAdapter)
53+
54+
def test_v6_session_mounts_lock_adapter(self):
55+
with IPForceSession(IPVersion.V6) as session:
56+
adapter = session.get_adapter('https://example.com')
57+
self.assertIsInstance(adapter, _BaseLockAdapter)
58+
self.assertEqual(adapter._family, socket.AF_INET6)
59+
60+
def test_session_with_global_method(self):
61+
with IPForceSession(IPVersion.V4, method=IPForceMethod.GLOBAL) as session:
62+
adapter = session.get_adapter('https://example.com')
63+
self.assertIsInstance(adapter, HTTPAdapter)
64+
65+
def test_session_context_manager(self):
66+
with IPForceSession(IPVersion.V4) as session:
67+
self.assertIsInstance(session, IPForceSession)
68+
69+
70+
class TestDeprecationWarnings(unittest.TestCase):
71+
"""Old v0.1 classes emit DeprecationWarning; new API does not."""
72+
73+
def test_ipv4_transport_adapter_warns(self):
74+
with warnings.catch_warnings(record=True) as w:
75+
warnings.simplefilter("always")
76+
IPv4TransportAdapter()
77+
self.assertEqual(len(w), 1)
78+
self.assertTrue(issubclass(w[0].category, DeprecationWarning))
79+
self.assertIn("IPForceAdapter", str(w[0].message))
80+
81+
def test_ipv6_transport_adapter_warns(self):
82+
with warnings.catch_warnings(record=True) as w:
83+
warnings.simplefilter("always")
84+
IPv6TransportAdapter()
85+
self.assertEqual(len(w), 1)
86+
self.assertTrue(issubclass(w[0].category, DeprecationWarning))
87+
88+
def test_new_api_does_not_warn(self):
89+
with warnings.catch_warnings(record=True) as w:
90+
warnings.simplefilter("always")
91+
IPForceAdapter(IPVersion.V4, IPForceMethod.LOCK)
92+
IPForceAdapter(IPVersion.V4, IPForceMethod.GLOBAL)
93+
session = IPForceSession(IPVersion.V4)
94+
session.close()
95+
dep_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)]
96+
self.assertEqual(len(dep_warnings), 0)
97+
98+
99+
class TestEnums(unittest.TestCase):
100+
"""Test enum values."""
101+
102+
def test_ip_version_values(self):
103+
self.assertEqual(IPVersion.V4.value, "ipv4")
104+
self.assertEqual(IPVersion.V6.value, "ipv6")
105+
106+
def test_method_values(self):
107+
self.assertEqual(IPForceMethod.GLOBAL.value, "global")
108+
self.assertEqual(IPForceMethod.LOCK.value, "lock")

0 commit comments

Comments
 (0)