Skip to content

Commit 02bf7e7

Browse files
committed
[python] honor proxy environment settings
urllib3 does not read proxy environment variables, so generated clients require users to copy them into Configuration.proxy. 97e079f added no_proxy handling, but 01ed597 replaced the Python templates without carrying it forward. Resolve scheme-specific proxy and no-proxy defaults through urllib.request while preserving explicit empty values as opt-outs. Match domain, port, IPv4 CIDR, and IPv6 CIDR bypass entries without adding requests to generated clients.
1 parent 83f31cb commit 02bf7e7

6 files changed

Lines changed: 202 additions & 2 deletions

File tree

modules/openapi-generator/src/main/resources/python/configuration.mustache

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import multiprocessing
1616
{{/async}}
1717
import sys
1818
from typing import Any, ClassVar, Dict, List, Literal, Optional, TypedDict, Union
19+
{{^async}}
20+
from urllib.parse import urlparse
21+
from urllib.request import getproxies
22+
{{/async}}
1923
from typing_extensions import NotRequired, Self
2024

2125
{{^async}}
@@ -215,6 +219,9 @@ class Configuration:
215219
:param tls_server_name: SSL/TLS Server Name Indication (SNI). Set this to the SNI value expected by the server.
216220
:param connection_pool_maxsize: Connection pool max size. None in the constructor is coerced to 100 for async and cpu_count * 5 for sync.
217221
:param proxy: Proxy URL.
222+
{{^async}}
223+
:param no_proxy: Comma-separated hosts that bypass the proxy.
224+
{{/async}}
218225
:param proxy_headers: Proxy headers.
219226
:param safe_chars_for_path_param: Safe characters for path parameter encoding.
220227
:param client_side_validation: Enable client-side validation. Default True.
@@ -346,6 +353,9 @@ conf = {{{packageName}}}.Configuration(
346353
tls_server_name: Optional[str]=None,
347354
connection_pool_maxsize: Optional[int]=None,
348355
proxy: Optional[str]=None,
356+
{{^async}}
357+
no_proxy: Optional[str]=None,
358+
{{/async}}
349359
proxy_headers: Optional[Any]=None,
350360
safe_chars_for_path_param: str='',
351361
client_side_validation: bool=True,
@@ -469,9 +479,25 @@ conf = {{{packageName}}}.Configuration(
469479
"""
470480
{{/async}}
471481

482+
{{^async}}
483+
# urllib3 does not read proxy environment variables itself:
484+
# https://github.com/urllib3/urllib3/issues/1785
485+
if proxy is None or no_proxy is None:
486+
proxies = getproxies()
487+
if proxy is None:
488+
scheme = urlparse(self.host).scheme
489+
proxy = proxies.get(scheme) or proxies.get("all")
490+
if no_proxy is None:
491+
no_proxy = proxies.get("no")
492+
{{/async}}
472493
self.proxy = proxy
473494
"""Proxy URL
474495
"""
496+
{{^async}}
497+
self.no_proxy = no_proxy
498+
"""Hosts that bypass the proxy
499+
"""
500+
{{/async}}
475501
self.proxy_headers = proxy_headers
476502
"""Proxy headers
477503
"""

modules/openapi-generator/src/main/resources/python/rest.mustache

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
{{>partial_header}}
44

55

6+
import ipaddress
67
import io
78
import json
89
import re
910
import ssl
11+
from urllib.parse import urlparse
1012

1113
import urllib3
1214

@@ -26,6 +28,43 @@ def is_socks_proxy_url(url):
2628
return split_section[0].lower() in SUPPORTED_SOCKS_PROXIES
2729

2830

31+
def should_bypass_proxies(url: str, no_proxy: str) -> bool:
32+
parsed_url = urlparse(url)
33+
if not parsed_url.hostname:
34+
return True
35+
36+
host = parsed_url.hostname.lower()
37+
host_and_port = parsed_url.netloc.lower()
38+
try:
39+
host_ip = ipaddress.ip_address(host)
40+
except ValueError:
41+
host_ip = None
42+
43+
for entry in (entry.strip().lower() for entry in no_proxy.split(',')):
44+
if not entry:
45+
continue
46+
if entry == '*':
47+
return True
48+
49+
if host_ip is not None:
50+
try:
51+
if host_ip in ipaddress.ip_network(entry, strict=False):
52+
return True
53+
except ValueError:
54+
pass
55+
56+
entry = entry.lstrip('.')
57+
if (
58+
host == entry
59+
or host.endswith('.' + entry)
60+
or host_and_port == entry
61+
or host_and_port.endswith('.' + entry)
62+
):
63+
return True
64+
65+
return False
66+
67+
2968
class RESTResponse(io.IOBase):
3069

3170
def __init__(self, resp) -> None:
@@ -95,7 +134,9 @@ class RESTClientObject:
95134
# https pool manager
96135
self.pool_manager: urllib3.PoolManager
97136

98-
if configuration.proxy:
137+
if configuration.proxy and not should_bypass_proxies(
138+
configuration.host, configuration.no_proxy or ''
139+
):
99140
if is_socks_proxy_url(configuration.proxy):
100141
from urllib3.contrib.socks import SOCKSProxyManager
101142
pool_args["proxy_url"] = configuration.proxy

samples/openapi3/client/petstore/python/petstore_api/configuration.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import multiprocessing
1818
import sys
1919
from typing import Any, ClassVar, Dict, List, Literal, Optional, TypedDict, Union
20+
from urllib.parse import urlparse
21+
from urllib.request import getproxies
2022
from typing_extensions import NotRequired, Self
2123

2224
import urllib3
@@ -177,6 +179,7 @@ class Configuration:
177179
:param tls_server_name: SSL/TLS Server Name Indication (SNI). Set this to the SNI value expected by the server.
178180
:param connection_pool_maxsize: Connection pool max size. None in the constructor is coerced to 100 for async and cpu_count * 5 for sync.
179181
:param proxy: Proxy URL.
182+
:param no_proxy: Comma-separated hosts that bypass the proxy.
180183
:param proxy_headers: Proxy headers.
181184
:param safe_chars_for_path_param: Safe characters for path parameter encoding.
182185
:param client_side_validation: Enable client-side validation. Default True.
@@ -287,6 +290,7 @@ def __init__(
287290
tls_server_name: Optional[str]=None,
288291
connection_pool_maxsize: Optional[int]=None,
289292
proxy: Optional[str]=None,
293+
no_proxy: Optional[str]=None,
290294
proxy_headers: Optional[Any]=None,
291295
safe_chars_for_path_param: str='',
292296
client_side_validation: bool=True,
@@ -398,9 +402,21 @@ def __init__(
398402
per pool. None in the constructor is coerced to cpu_count * 5.
399403
"""
400404

405+
# urllib3 does not read proxy environment variables itself:
406+
# https://github.com/urllib3/urllib3/issues/1785
407+
if proxy is None or no_proxy is None:
408+
proxies = getproxies()
409+
if proxy is None:
410+
scheme = urlparse(self.host).scheme
411+
proxy = proxies.get(scheme) or proxies.get("all")
412+
if no_proxy is None:
413+
no_proxy = proxies.get("no")
401414
self.proxy = proxy
402415
"""Proxy URL
403416
"""
417+
self.no_proxy = no_proxy
418+
"""Hosts that bypass the proxy
419+
"""
404420
self.proxy_headers = proxy_headers
405421
"""Proxy headers
406422
"""

samples/openapi3/client/petstore/python/petstore_api/rest.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
""" # noqa: E501
1313

1414

15+
import ipaddress
1516
import io
1617
import json
1718
import re
1819
import ssl
20+
from urllib.parse import urlparse
1921

2022
import urllib3
2123

@@ -35,6 +37,43 @@ def is_socks_proxy_url(url):
3537
return split_section[0].lower() in SUPPORTED_SOCKS_PROXIES
3638

3739

40+
def should_bypass_proxies(url: str, no_proxy: str) -> bool:
41+
parsed_url = urlparse(url)
42+
if not parsed_url.hostname:
43+
return True
44+
45+
host = parsed_url.hostname.lower()
46+
host_and_port = parsed_url.netloc.lower()
47+
try:
48+
host_ip = ipaddress.ip_address(host)
49+
except ValueError:
50+
host_ip = None
51+
52+
for entry in (entry.strip().lower() for entry in no_proxy.split(',')):
53+
if not entry:
54+
continue
55+
if entry == '*':
56+
return True
57+
58+
if host_ip is not None:
59+
try:
60+
if host_ip in ipaddress.ip_network(entry, strict=False):
61+
return True
62+
except ValueError:
63+
pass
64+
65+
entry = entry.lstrip('.')
66+
if (
67+
host == entry
68+
or host.endswith('.' + entry)
69+
or host_and_port == entry
70+
or host_and_port.endswith('.' + entry)
71+
):
72+
return True
73+
74+
return False
75+
76+
3877
class RESTResponse(io.IOBase):
3978

4079
def __init__(self, resp) -> None:
@@ -104,7 +143,9 @@ def __init__(self, configuration) -> None:
104143
# https pool manager
105144
self.pool_manager: urllib3.PoolManager
106145

107-
if configuration.proxy:
146+
if configuration.proxy and not should_bypass_proxies(
147+
configuration.host, configuration.no_proxy or ''
148+
):
108149
if is_socks_proxy_url(configuration.proxy):
109150
from urllib3.contrib.socks import SOCKSProxyManager
110151
pool_args["proxy_url"] = configuration.proxy

samples/openapi3/client/petstore/python/tests/test_configuration.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import absolute_import
1212

1313
import unittest
14+
from unittest.mock import patch
1415

1516
import petstore_api
1617

@@ -58,6 +59,48 @@ def testAccessTokenWhenConstructingConfiguration(self):
5859
c1 = petstore_api.Configuration(access_token="12345")
5960
self.assertEqual(c1.access_token, "12345")
6061

62+
def test_proxy_settings_default_from_environment(self):
63+
environment_proxies = {
64+
"http": "http://plain-proxy.example",
65+
"https": "http://secure-proxy.example",
66+
"no": "internal.example",
67+
}
68+
with patch(
69+
"petstore_api.configuration.getproxies",
70+
return_value=environment_proxies,
71+
):
72+
config = petstore_api.Configuration(host="https://api.example")
73+
74+
self.assertEqual(config.proxy, "http://secure-proxy.example")
75+
self.assertEqual(config.no_proxy, "internal.example")
76+
77+
def test_explicit_proxy_settings_override_environment(self):
78+
with patch(
79+
"petstore_api.configuration.getproxies",
80+
return_value={"https": "http://proxy.example", "no": "example"},
81+
) as getproxies:
82+
config = petstore_api.Configuration(
83+
host="https://api.example",
84+
proxy="",
85+
no_proxy="",
86+
)
87+
88+
getproxies.assert_not_called()
89+
self.assertEqual(config.proxy, "")
90+
self.assertEqual(config.no_proxy, "")
91+
92+
def test_explicit_proxy_does_not_resolve_generated_host(self):
93+
with patch(
94+
"petstore_api.configuration.getproxies",
95+
return_value={},
96+
):
97+
config = petstore_api.Configuration(
98+
server_index=999,
99+
proxy="http://proxy.example",
100+
)
101+
102+
self.assertEqual(config.proxy, "http://proxy.example")
103+
61104
def test_ignore_operation_servers(self):
62105
self.config.ignore_operation_servers = True
63106
self.assertTrue(self.config.ignore_operation_servers)

samples/openapi3/client/petstore/python/tests/test_rest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,39 @@
33
from unittest.mock import Mock, patch
44

55
import petstore_api
6+
from petstore_api.rest import RESTClientObject
7+
8+
9+
class TestProxyConnection(unittest.TestCase):
10+
def test_no_proxy_selects_direct_connection(self):
11+
cases = [
12+
("https://api.example.com", "example.com", True),
13+
("https://api.example.com:8443", "example.com:8443", True),
14+
("https://example.com:443", "example.com:8443", False),
15+
("https://10.2.3.4", "10.0.0.0/8", True),
16+
("https://[2001:db8::1]", "2001:db8::/32", True),
17+
("https://api.example.net", "*", True),
18+
("https://api.example.net", "example.com", False),
19+
]
20+
for host, no_proxy, bypasses_proxy in cases:
21+
with self.subTest(host=host, no_proxy=no_proxy):
22+
config = petstore_api.Configuration(
23+
host=host,
24+
proxy="http://proxy.example",
25+
no_proxy=no_proxy,
26+
)
27+
with (
28+
patch("petstore_api.rest.urllib3.PoolManager") as direct,
29+
patch("petstore_api.rest.urllib3.ProxyManager") as proxied,
30+
):
31+
RESTClientObject(config)
32+
33+
if bypasses_proxy:
34+
direct.assert_called_once()
35+
proxied.assert_not_called()
36+
else:
37+
direct.assert_not_called()
38+
proxied.assert_called_once()
639

740

841
class TestMultipleResponseTypes(unittest.TestCase):

0 commit comments

Comments
 (0)