nodemaven 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodemaven-0.1.0/LICENSE +21 -0
- nodemaven-0.1.0/PKG-INFO +182 -0
- nodemaven-0.1.0/README.md +157 -0
- nodemaven-0.1.0/pyproject.toml +43 -0
- nodemaven-0.1.0/setup.cfg +4 -0
- nodemaven-0.1.0/src/nodemaven/__init__.py +42 -0
- nodemaven-0.1.0/src/nodemaven/data/providers/nodemaven.toml +53 -0
- nodemaven-0.1.0/src/nodemaven/errors.py +26 -0
- nodemaven-0.1.0/src/nodemaven/providers.py +158 -0
- nodemaven-0.1.0/src/nodemaven/proxy.py +274 -0
- nodemaven-0.1.0/src/nodemaven/py.typed +0 -0
- nodemaven-0.1.0/src/nodemaven.egg-info/PKG-INFO +182 -0
- nodemaven-0.1.0/src/nodemaven.egg-info/SOURCES.txt +15 -0
- nodemaven-0.1.0/src/nodemaven.egg-info/dependency_links.txt +1 -0
- nodemaven-0.1.0/src/nodemaven.egg-info/requires.txt +3 -0
- nodemaven-0.1.0/src/nodemaven.egg-info/top_level.txt +1 -0
- nodemaven-0.1.0/tests/test_proxy.py +131 -0
nodemaven-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NodeMaven
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nodemaven-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodemaven
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and validate proxy connection strings. No transport, no retries.
|
|
5
|
+
Author-email: NodeMaven <alexandr.kazmin@nodemaven.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nodemaven.com
|
|
8
|
+
Keywords: proxy,residential-proxy,scraping,http-proxy,playwright
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# nodemaven
|
|
27
|
+
|
|
28
|
+
Build and validate proxy connection strings.
|
|
29
|
+
|
|
30
|
+
This library opens no socket. It builds the username a proxy gateway expects,
|
|
31
|
+
refuses the input that gateway would mishandle, and hands the result to whatever
|
|
32
|
+
HTTP client you already use.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install nodemaven
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import requests
|
|
42
|
+
from nodemaven import Proxy
|
|
43
|
+
|
|
44
|
+
proxy = Proxy(login="your-login", password="your-password",
|
|
45
|
+
country="us", filter="medium")
|
|
46
|
+
|
|
47
|
+
r = requests.get("https://api.ipify.org", proxies=proxy.requests())
|
|
48
|
+
print(r.text)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Credentials can come from the environment instead, so nothing is in your source:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# NODEMAVEN_LOGIN and NODEMAVEN_PASSWORD
|
|
55
|
+
proxy = Proxy(country="us", filter="medium")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The same identity, for other clients:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
proxy.url() # http://user:pass@gate.nodemaven.com:8080 - httpx, aiohttp, curl
|
|
62
|
+
proxy.requests() # {"http": ..., "https": ...}
|
|
63
|
+
proxy.httpx() # {"http://": ..., "https://": ...}
|
|
64
|
+
proxy.playwright() # {"server": ..., "username": ..., "password": ...}
|
|
65
|
+
proxy.username # the username on its own
|
|
66
|
+
proxy.server # host:port, no credentials
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With Playwright, Patchright or Puppeteer:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from playwright.sync_api import sync_playwright
|
|
73
|
+
|
|
74
|
+
with sync_playwright() as p:
|
|
75
|
+
browser = p.chromium.launch()
|
|
76
|
+
context = browser.new_context(proxy=proxy.playwright())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Sticky sessions
|
|
80
|
+
|
|
81
|
+
One `Proxy` is one identity. Pin it to a sticky session:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
held = proxy.session("order-4417")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**The session key is the whole parameter set, not the session id.**
|
|
88
|
+
`country=us, sid=A` and `country=us, sid=A, filter=medium` are two different
|
|
89
|
+
sessions on the gateway, so adding or removing any parameter moves you to a
|
|
90
|
+
different exit address. That is why parameters change through a method that
|
|
91
|
+
returns a new object rather than by assignment - the move is a different
|
|
92
|
+
identity, and the code should say so:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
germany = proxy.replace(country="de") # a new identity, a new exit
|
|
96
|
+
plain = proxy.replace(filter=None) # also a new identity
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Why the validation is the point
|
|
100
|
+
|
|
101
|
+
A gateway is bad at telling you that you got the username wrong. Measured
|
|
102
|
+
against this one on 2026-08-10, seven kinds of bad input produce seven
|
|
103
|
+
different reactions and not one of them names the cause:
|
|
104
|
+
|
|
105
|
+
| you sent | the gateway answers |
|
|
106
|
+
|---|---|
|
|
107
|
+
| bad country | `406 Not Acceptable` |
|
|
108
|
+
| bad region | `406 Not Acceptable` |
|
|
109
|
+
| bad city | `500 Internal Server Error` |
|
|
110
|
+
| bad `filter` value | `407 Proxy Authentication Required` |
|
|
111
|
+
| bad `ttl` value | `407 Proxy Authentication Required` |
|
|
112
|
+
| empty value | nothing, the connection hangs about 20 s |
|
|
113
|
+
| **unknown parameter name** | **`200`, and the parameter is ignored** |
|
|
114
|
+
|
|
115
|
+
The two `407` replies send you to check credentials that are correct. The last
|
|
116
|
+
row is worse than any of them: the request succeeds, your code carries on, and
|
|
117
|
+
the setting you asked for was never applied. Nothing that comes back over the
|
|
118
|
+
wire can tell you.
|
|
119
|
+
|
|
120
|
+
So this library checks before anything is sent:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
>>> Proxy(login="u", password="p", contry="us")
|
|
124
|
+
ParamError: NodeMaven does not know the parameter 'contry': it is answered with
|
|
125
|
+
200 and dropped, so the connection would succeed and your setting would NOT be
|
|
126
|
+
applied. Known: ['city', 'country', 'filter', 'ipv4', 'isp', 'region', 'sid',
|
|
127
|
+
'speed', 'ttl']
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## What this library does not do
|
|
131
|
+
|
|
132
|
+
**It does not retry.** That is deliberate, and it is the one design decision
|
|
133
|
+
here taken against a measurement rather than a preference.
|
|
134
|
+
|
|
135
|
+
Retrying a refused request is the thing that most reliably makes the next one
|
|
136
|
+
worse: each retry confirms automation to the target and burns the exit range for
|
|
137
|
+
everyone else sharing the pool. Measured over 1464 attempts, the chance that the
|
|
138
|
+
next attempt succeeds, by how many failures came immediately before it:
|
|
139
|
+
|
|
140
|
+
| failures before | P(next attempt succeeds) |
|
|
141
|
+
|---|---|
|
|
142
|
+
| 0 | 75% |
|
|
143
|
+
| 1 | 21% |
|
|
144
|
+
| 3 | 5.9% |
|
|
145
|
+
| 5 | 5.8% |
|
|
146
|
+
| 6 | 1.6% |
|
|
147
|
+
| 7-9 | 0.5% |
|
|
148
|
+
|
|
149
|
+
294 attempts were spent past six consecutive failures and returned 3 pages - 98
|
|
150
|
+
attempts per delivered page, against 1.7 in a healthy session. A library that
|
|
151
|
+
shipped automatic retry as a default would be spending that on your behalf
|
|
152
|
+
without telling you.
|
|
153
|
+
|
|
154
|
+
It also does not own an HTTP client, a connection pool or a browser. Those are
|
|
155
|
+
yours, and they are better than anything a vendor SDK would bundle.
|
|
156
|
+
|
|
157
|
+
## Other gateways
|
|
158
|
+
|
|
159
|
+
Parameters are data, not hardcoded keywords. A gateway is one TOML file - its
|
|
160
|
+
prefix, separators, session parameter and the set of parameter names it actually
|
|
161
|
+
recognises - and a definition read from disk goes through the same builder and
|
|
162
|
+
the same validation as the one shipped here:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from nodemaven import Proxy, load_file
|
|
166
|
+
|
|
167
|
+
mine = load_file("my-gateway.toml")
|
|
168
|
+
proxy = Proxy(provider=mine, login="u", password="p", country="us")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every definition carries a `status`. `measured` means traffic has gone through
|
|
172
|
+
that gateway and the dialect was read off the wire. `documented` means it was
|
|
173
|
+
transcribed from documentation and never exercised. Only `nodemaven` is shipped
|
|
174
|
+
here, and it is `measured`.
|
|
175
|
+
|
|
176
|
+
## Requirements
|
|
177
|
+
|
|
178
|
+
Python 3.9 or newer. No dependencies on 3.11 and newer; `tomli` on older ones.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# nodemaven
|
|
2
|
+
|
|
3
|
+
Build and validate proxy connection strings.
|
|
4
|
+
|
|
5
|
+
This library opens no socket. It builds the username a proxy gateway expects,
|
|
6
|
+
refuses the input that gateway would mishandle, and hands the result to whatever
|
|
7
|
+
HTTP client you already use.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install nodemaven
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import requests
|
|
17
|
+
from nodemaven import Proxy
|
|
18
|
+
|
|
19
|
+
proxy = Proxy(login="your-login", password="your-password",
|
|
20
|
+
country="us", filter="medium")
|
|
21
|
+
|
|
22
|
+
r = requests.get("https://api.ipify.org", proxies=proxy.requests())
|
|
23
|
+
print(r.text)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Credentials can come from the environment instead, so nothing is in your source:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# NODEMAVEN_LOGIN and NODEMAVEN_PASSWORD
|
|
30
|
+
proxy = Proxy(country="us", filter="medium")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The same identity, for other clients:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
proxy.url() # http://user:pass@gate.nodemaven.com:8080 - httpx, aiohttp, curl
|
|
37
|
+
proxy.requests() # {"http": ..., "https": ...}
|
|
38
|
+
proxy.httpx() # {"http://": ..., "https://": ...}
|
|
39
|
+
proxy.playwright() # {"server": ..., "username": ..., "password": ...}
|
|
40
|
+
proxy.username # the username on its own
|
|
41
|
+
proxy.server # host:port, no credentials
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
With Playwright, Patchright or Puppeteer:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from playwright.sync_api import sync_playwright
|
|
48
|
+
|
|
49
|
+
with sync_playwright() as p:
|
|
50
|
+
browser = p.chromium.launch()
|
|
51
|
+
context = browser.new_context(proxy=proxy.playwright())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Sticky sessions
|
|
55
|
+
|
|
56
|
+
One `Proxy` is one identity. Pin it to a sticky session:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
held = proxy.session("order-4417")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**The session key is the whole parameter set, not the session id.**
|
|
63
|
+
`country=us, sid=A` and `country=us, sid=A, filter=medium` are two different
|
|
64
|
+
sessions on the gateway, so adding or removing any parameter moves you to a
|
|
65
|
+
different exit address. That is why parameters change through a method that
|
|
66
|
+
returns a new object rather than by assignment - the move is a different
|
|
67
|
+
identity, and the code should say so:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
germany = proxy.replace(country="de") # a new identity, a new exit
|
|
71
|
+
plain = proxy.replace(filter=None) # also a new identity
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Why the validation is the point
|
|
75
|
+
|
|
76
|
+
A gateway is bad at telling you that you got the username wrong. Measured
|
|
77
|
+
against this one on 2026-08-10, seven kinds of bad input produce seven
|
|
78
|
+
different reactions and not one of them names the cause:
|
|
79
|
+
|
|
80
|
+
| you sent | the gateway answers |
|
|
81
|
+
|---|---|
|
|
82
|
+
| bad country | `406 Not Acceptable` |
|
|
83
|
+
| bad region | `406 Not Acceptable` |
|
|
84
|
+
| bad city | `500 Internal Server Error` |
|
|
85
|
+
| bad `filter` value | `407 Proxy Authentication Required` |
|
|
86
|
+
| bad `ttl` value | `407 Proxy Authentication Required` |
|
|
87
|
+
| empty value | nothing, the connection hangs about 20 s |
|
|
88
|
+
| **unknown parameter name** | **`200`, and the parameter is ignored** |
|
|
89
|
+
|
|
90
|
+
The two `407` replies send you to check credentials that are correct. The last
|
|
91
|
+
row is worse than any of them: the request succeeds, your code carries on, and
|
|
92
|
+
the setting you asked for was never applied. Nothing that comes back over the
|
|
93
|
+
wire can tell you.
|
|
94
|
+
|
|
95
|
+
So this library checks before anything is sent:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
>>> Proxy(login="u", password="p", contry="us")
|
|
99
|
+
ParamError: NodeMaven does not know the parameter 'contry': it is answered with
|
|
100
|
+
200 and dropped, so the connection would succeed and your setting would NOT be
|
|
101
|
+
applied. Known: ['city', 'country', 'filter', 'ipv4', 'isp', 'region', 'sid',
|
|
102
|
+
'speed', 'ttl']
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## What this library does not do
|
|
106
|
+
|
|
107
|
+
**It does not retry.** That is deliberate, and it is the one design decision
|
|
108
|
+
here taken against a measurement rather than a preference.
|
|
109
|
+
|
|
110
|
+
Retrying a refused request is the thing that most reliably makes the next one
|
|
111
|
+
worse: each retry confirms automation to the target and burns the exit range for
|
|
112
|
+
everyone else sharing the pool. Measured over 1464 attempts, the chance that the
|
|
113
|
+
next attempt succeeds, by how many failures came immediately before it:
|
|
114
|
+
|
|
115
|
+
| failures before | P(next attempt succeeds) |
|
|
116
|
+
|---|---|
|
|
117
|
+
| 0 | 75% |
|
|
118
|
+
| 1 | 21% |
|
|
119
|
+
| 3 | 5.9% |
|
|
120
|
+
| 5 | 5.8% |
|
|
121
|
+
| 6 | 1.6% |
|
|
122
|
+
| 7-9 | 0.5% |
|
|
123
|
+
|
|
124
|
+
294 attempts were spent past six consecutive failures and returned 3 pages - 98
|
|
125
|
+
attempts per delivered page, against 1.7 in a healthy session. A library that
|
|
126
|
+
shipped automatic retry as a default would be spending that on your behalf
|
|
127
|
+
without telling you.
|
|
128
|
+
|
|
129
|
+
It also does not own an HTTP client, a connection pool or a browser. Those are
|
|
130
|
+
yours, and they are better than anything a vendor SDK would bundle.
|
|
131
|
+
|
|
132
|
+
## Other gateways
|
|
133
|
+
|
|
134
|
+
Parameters are data, not hardcoded keywords. A gateway is one TOML file - its
|
|
135
|
+
prefix, separators, session parameter and the set of parameter names it actually
|
|
136
|
+
recognises - and a definition read from disk goes through the same builder and
|
|
137
|
+
the same validation as the one shipped here:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from nodemaven import Proxy, load_file
|
|
141
|
+
|
|
142
|
+
mine = load_file("my-gateway.toml")
|
|
143
|
+
proxy = Proxy(provider=mine, login="u", password="p", country="us")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Every definition carries a `status`. `measured` means traffic has gone through
|
|
147
|
+
that gateway and the dialect was read off the wire. `documented` means it was
|
|
148
|
+
transcribed from documentation and never exercised. Only `nodemaven` is shipped
|
|
149
|
+
here, and it is `measured`.
|
|
150
|
+
|
|
151
|
+
## Requirements
|
|
152
|
+
|
|
153
|
+
Python 3.9 or newer. No dependencies on 3.11 and newer; `tomli` on older ones.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodemaven"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Build and validate proxy connection strings. No transport, no retries."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "NodeMaven", email = "alexandr.kazmin@nodemaven.com" }]
|
|
14
|
+
keywords = ["proxy", "residential-proxy", "scraping", "http-proxy", "playwright"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Internet :: Proxy Servers",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["tomli>=1.1.0; python_version < '3.11'"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://nodemaven.com"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.dynamic]
|
|
34
|
+
version = { attr = "nodemaven.__version__" }
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
nodemaven = ["py.typed", "data/providers/*.toml"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""nodemaven - build and validate proxy connection strings.
|
|
2
|
+
|
|
3
|
+
This package builds the username a proxy gateway expects, refuses the input that
|
|
4
|
+
gateway would mishandle, and hands the result to whatever HTTP client you
|
|
5
|
+
already use. It opens no socket, holds no session and retries nothing.
|
|
6
|
+
|
|
7
|
+
>>> from nodemaven import Proxy
|
|
8
|
+
>>> proxy = Proxy(login="user", password="pass", country="us", filter="medium")
|
|
9
|
+
>>> requests.get("https://api.ipify.org", proxies=proxy.requests())
|
|
10
|
+
|
|
11
|
+
There is no retry policy here on purpose. Retrying a refused request is the one
|
|
12
|
+
thing that reliably makes the next one worse: measured over 1464 attempts, the
|
|
13
|
+
chance the next attempt succeeds falls from 75% with no prior failure to 5.8%
|
|
14
|
+
after five and 0.5% after seven, and 294 attempts spent past six consecutive
|
|
15
|
+
failures returned three pages - 98 attempts per delivered page against 1.7 in a
|
|
16
|
+
healthy session. A library that hid that behind a default would be spending a
|
|
17
|
+
shared pool's reputation on your behalf.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .errors import (
|
|
21
|
+
CredentialsError,
|
|
22
|
+
NodeMavenError,
|
|
23
|
+
ParamError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
)
|
|
26
|
+
from .providers import Provider, available, load, load_file
|
|
27
|
+
from .proxy import Proxy
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Proxy",
|
|
33
|
+
"Provider",
|
|
34
|
+
"load",
|
|
35
|
+
"load_file",
|
|
36
|
+
"available",
|
|
37
|
+
"NodeMavenError",
|
|
38
|
+
"ParamError",
|
|
39
|
+
"CredentialsError",
|
|
40
|
+
"ProviderError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# NodeMaven.
|
|
2
|
+
#
|
|
3
|
+
# `status = "measured"` means this dialect was read off the wire rather than
|
|
4
|
+
# copied out of documentation: every value below was confirmed by raw CONNECT
|
|
5
|
+
# probes on the date recorded. A definition marked `documented` has never been
|
|
6
|
+
# exercised, and the difference matters because a wrong username is invisible -
|
|
7
|
+
# see `notes`.
|
|
8
|
+
label = "NodeMaven"
|
|
9
|
+
status = "measured"
|
|
10
|
+
source = "dashboard proxy generator, plus raw CONNECT probes"
|
|
11
|
+
source_read = "2026-08-12"
|
|
12
|
+
|
|
13
|
+
# <login>-country-us-sid-abc123-ttl-10m
|
|
14
|
+
prefix = "{login}"
|
|
15
|
+
separator = "-"
|
|
16
|
+
pair_separator = "-"
|
|
17
|
+
session_param = "sid"
|
|
18
|
+
|
|
19
|
+
host = "gate.nodemaven.com"
|
|
20
|
+
port = 8080
|
|
21
|
+
|
|
22
|
+
# Returned on the CONNECT reply, so an exit address costs one handshake and no
|
|
23
|
+
# target traffic. Not guaranteed: more than one implementation answers on this
|
|
24
|
+
# name, and only some of them send it, so handle its absence rather than
|
|
25
|
+
# treating the first miss as an error.
|
|
26
|
+
exit_ip_header = "X-Proxy-Exit-IP"
|
|
27
|
+
|
|
28
|
+
# Confirmed to be recognised. Anything outside this set is refused before a
|
|
29
|
+
# request exists, because the gateway answers an unknown name with 200 and the
|
|
30
|
+
# setting silently dropped.
|
|
31
|
+
known_params = ["country", "region", "city", "isp", "sid", "ttl", "filter",
|
|
32
|
+
"ipv4", "speed"]
|
|
33
|
+
|
|
34
|
+
notes = """
|
|
35
|
+
Measured 2026-08-10 by raw CONNECT probes.
|
|
36
|
+
|
|
37
|
+
Sticky sessions key on the whole recognised parameter set, not on `sid` alone.
|
|
38
|
+
`country=us, sid=A` and `country=us, sid=A, filter=medium` are two different
|
|
39
|
+
sessions; `ttl` does not participate and `filter` does. Adding or removing any
|
|
40
|
+
parameter moves you to another exit without saying so.
|
|
41
|
+
|
|
42
|
+
Seven inputs, seven reactions, none of which names the cause:
|
|
43
|
+
bad country 406 Not Acceptable
|
|
44
|
+
bad region 406 Not Acceptable
|
|
45
|
+
bad city 500 Internal Server Error
|
|
46
|
+
bad filter value 407 Proxy Authentication Required
|
|
47
|
+
bad ttl value 407 Proxy Authentication Required
|
|
48
|
+
empty value no reply, the connection hangs about 20 s
|
|
49
|
+
unknown parameter 200, the parameter is ignored
|
|
50
|
+
|
|
51
|
+
The two 407 cases are actively misleading - they send you to check credentials
|
|
52
|
+
that are correct. With no `country` the default country is not stable.
|
|
53
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Errors this package raises.
|
|
2
|
+
|
|
3
|
+
Every message says what will happen to the caller, not that a value is invalid.
|
|
4
|
+
A gateway parameter that is wrong is not a style problem: the request usually
|
|
5
|
+
still succeeds, on settings nobody asked for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["NodeMavenError", "ParamError", "CredentialsError", "ProviderError"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NodeMavenError(Exception):
|
|
14
|
+
"""Base class, so a caller can catch everything from this package at once."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParamError(NodeMavenError, ValueError):
|
|
18
|
+
"""Input the gateway would silently ignore, misreport, or hang on."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CredentialsError(NodeMavenError, ValueError):
|
|
22
|
+
"""No login or password was given and none was found in the environment."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderError(NodeMavenError, ValueError):
|
|
26
|
+
"""A provider definition is missing or does not describe a gateway."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""A gateway dialect is data, never code.
|
|
2
|
+
|
|
3
|
+
Every proxy vendor sells the same thing and spells it differently: its own
|
|
4
|
+
separators, its own parameter names, its own reaction to a mistake. That
|
|
5
|
+
difference is the whole of what a provider is from this package's point of view,
|
|
6
|
+
so it is one TOML file rather than one Python module.
|
|
7
|
+
|
|
8
|
+
The reason is not tidiness. A module invites a single ``if provider == ...``
|
|
9
|
+
somewhere nobody reviews, and from then on two gateways are no longer going
|
|
10
|
+
through the same code path. A data file cannot branch.
|
|
11
|
+
|
|
12
|
+
``load()`` reads a definition shipped inside the package. ``load_file()`` reads
|
|
13
|
+
one from disk, which is how this SDK talks to a gateway we do not ship a
|
|
14
|
+
definition for - your own, or one you wrote yourself - without this package
|
|
15
|
+
having to make any claim about it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, FrozenSet, Optional
|
|
24
|
+
|
|
25
|
+
from .errors import ProviderError
|
|
26
|
+
|
|
27
|
+
if sys.version_info >= (3, 11): # pragma: no cover - version dependent
|
|
28
|
+
import tomllib
|
|
29
|
+
else: # pragma: no cover - version dependent
|
|
30
|
+
import tomli as tomllib
|
|
31
|
+
|
|
32
|
+
__all__ = ["Provider", "load", "load_file", "available"]
|
|
33
|
+
|
|
34
|
+
DEFAULT_PROVIDER = "nodemaven"
|
|
35
|
+
|
|
36
|
+
_REQUIRED = ("label", "known_params")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, eq=False)
|
|
40
|
+
class Provider:
|
|
41
|
+
"""One gateway's username dialect.
|
|
42
|
+
|
|
43
|
+
``status`` is load-bearing and not documentation. ``measured`` means traffic
|
|
44
|
+
has actually gone through this gateway and the dialect was read off the
|
|
45
|
+
wire. ``documented`` means it was transcribed from a vendor's own
|
|
46
|
+
documentation on ``source_read`` and has never been exercised. The
|
|
47
|
+
distinction matters because a wrong username is invisible: at least one
|
|
48
|
+
gateway answers an unrecognised parameter name with 200 and the setting
|
|
49
|
+
silently dropped, so the connection succeeds on settings that were never
|
|
50
|
+
applied and nothing the gateway replies can tell you.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
label: str
|
|
55
|
+
known_params: FrozenSet[str]
|
|
56
|
+
status: str = "documented"
|
|
57
|
+
prefix: str = "{login}"
|
|
58
|
+
separator: str = "-"
|
|
59
|
+
pair_separator: str = "-"
|
|
60
|
+
session_param: Optional[str] = None
|
|
61
|
+
host: Optional[str] = None
|
|
62
|
+
port: Optional[int] = None
|
|
63
|
+
aliases: Dict[str, str] = field(default_factory=dict)
|
|
64
|
+
exit_ip_header: Optional[str] = None
|
|
65
|
+
source: str = ""
|
|
66
|
+
source_read: str = ""
|
|
67
|
+
notes: str = ""
|
|
68
|
+
|
|
69
|
+
def spell(self, name: str) -> str:
|
|
70
|
+
"""The name this gateway uses on the wire for a canonical parameter."""
|
|
71
|
+
return self.aliases.get(name, name)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_measured(self) -> bool:
|
|
75
|
+
return self.status == "measured"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _data_dir() -> Path:
|
|
79
|
+
return Path(__file__).resolve().parent / "data" / "providers"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def available() -> list:
|
|
83
|
+
"""Ids of the definitions shipped inside this package."""
|
|
84
|
+
return sorted(p.stem for p in _data_dir().glob("*.toml")
|
|
85
|
+
if not p.stem.startswith("_"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load(provider_id: str = DEFAULT_PROVIDER) -> Provider:
|
|
89
|
+
"""A definition shipped with this package."""
|
|
90
|
+
path = _data_dir() / f"{provider_id}.toml"
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
raise ProviderError(
|
|
93
|
+
f"no provider definition {provider_id!r} is shipped here, so no "
|
|
94
|
+
f"username can be built for it. Shipped: {available()}. To use a "
|
|
95
|
+
f"gateway that is not in that list, write a .toml for it and pass "
|
|
96
|
+
f"it to load_file()."
|
|
97
|
+
)
|
|
98
|
+
return load_file(path, provider_id=provider_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_file(path, provider_id: Optional[str] = None) -> Provider:
|
|
102
|
+
"""A definition read from an arbitrary path.
|
|
103
|
+
|
|
104
|
+
This is the seam that lets the package address a gateway it ships no
|
|
105
|
+
definition for. Nothing about it is special-cased: a file loaded from disk
|
|
106
|
+
goes through the same validation and the same builder as a shipped one.
|
|
107
|
+
"""
|
|
108
|
+
path = Path(path)
|
|
109
|
+
try:
|
|
110
|
+
with path.open("rb") as handle:
|
|
111
|
+
raw: Dict[str, Any] = tomllib.load(handle)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ProviderError(f"cannot read the provider definition {path}: {exc}") from exc
|
|
114
|
+
except tomllib.TOMLDecodeError as exc:
|
|
115
|
+
raise ProviderError(f"{path} is not valid TOML: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
missing = [key for key in _REQUIRED if key not in raw]
|
|
118
|
+
if missing:
|
|
119
|
+
raise ProviderError(
|
|
120
|
+
f"{path} is missing {missing}, so it does not describe a gateway. "
|
|
121
|
+
f"Without known_params nothing can be validated, and an unknown "
|
|
122
|
+
f"parameter name is the one mistake a gateway does not report."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
known = frozenset(str(name) for name in raw["known_params"])
|
|
126
|
+
aliases = {str(k): str(v) for k, v in (raw.get("aliases") or {}).items()}
|
|
127
|
+
unknown_alias = sorted(set(aliases) - known)
|
|
128
|
+
if unknown_alias:
|
|
129
|
+
raise ProviderError(
|
|
130
|
+
f"{path} aliases {unknown_alias} which are not in known_params, so "
|
|
131
|
+
f"those parameters would be refused before the alias was ever used."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
session_param = raw.get("session_param") or None
|
|
135
|
+
if session_param and session_param not in known:
|
|
136
|
+
raise ProviderError(
|
|
137
|
+
f"{path} declares session_param {session_param!r} which is not in "
|
|
138
|
+
f"known_params, so every sticky session would be refused."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
port = raw.get("port")
|
|
142
|
+
return Provider(
|
|
143
|
+
id=provider_id or path.stem,
|
|
144
|
+
label=str(raw["label"]),
|
|
145
|
+
known_params=known,
|
|
146
|
+
status=str(raw.get("status", "documented")),
|
|
147
|
+
prefix=str(raw.get("prefix", "{login}")),
|
|
148
|
+
separator=str(raw.get("separator", "-")),
|
|
149
|
+
pair_separator=str(raw.get("pair_separator", "-")),
|
|
150
|
+
session_param=session_param,
|
|
151
|
+
host=raw.get("host"),
|
|
152
|
+
port=int(port) if port is not None else None,
|
|
153
|
+
aliases=aliases,
|
|
154
|
+
exit_ip_header=raw.get("exit_ip_header"),
|
|
155
|
+
source=str(raw.get("source", "")),
|
|
156
|
+
source_read=str(raw.get("source_read", "")),
|
|
157
|
+
notes=str(raw.get("notes", "")),
|
|
158
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""One proxy identity, and the strings a client needs to use it.
|
|
2
|
+
|
|
3
|
+
This module opens no socket. It builds a username in the gateway's dialect,
|
|
4
|
+
refuses input the gateway would mishandle, and hands the result to whatever HTTP
|
|
5
|
+
client you already have.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Dict, Mapping, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from .errors import CredentialsError, ParamError
|
|
15
|
+
from .providers import Provider, load
|
|
16
|
+
|
|
17
|
+
__all__ = ["Proxy"]
|
|
18
|
+
|
|
19
|
+
# Constructor names that are ours rather than the gateway's. A definition whose
|
|
20
|
+
# known_params collide with one of these is refused at construction, because the
|
|
21
|
+
# alternative is a parameter silently arriving as a credential or the reverse.
|
|
22
|
+
_RESERVED = frozenset({"login", "password", "host", "port", "provider"})
|
|
23
|
+
|
|
24
|
+
_REDACTED = "***"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Proxy:
|
|
28
|
+
"""A set of gateway parameters plus the credentials to use them.
|
|
29
|
+
|
|
30
|
+
::
|
|
31
|
+
|
|
32
|
+
proxy = Proxy(login="user", password="pass", country="us", sid="a1")
|
|
33
|
+
requests.get(url, proxies=proxy.requests())
|
|
34
|
+
|
|
35
|
+
Credentials fall back to the environment when not passed, under the
|
|
36
|
+
provider's id in upper case: ``NODEMAVEN_LOGIN``, ``NODEMAVEN_PASSWORD``,
|
|
37
|
+
``NODEMAVEN_HOST``, ``NODEMAVEN_PORT``.
|
|
38
|
+
|
|
39
|
+
**One instance is one identity.** On the gateway this package ships a
|
|
40
|
+
definition for, the sticky session key is the whole recognised parameter
|
|
41
|
+
set and not the session id alone: ``country=us, sid=A`` and
|
|
42
|
+
``country=us, sid=A, filter=medium`` are two different sessions, and adding
|
|
43
|
+
or removing any parameter moves you to a different exit address without
|
|
44
|
+
saying so. Build the object once and reuse it; use :meth:`replace` when you
|
|
45
|
+
intend to move, so that the move is written down.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_provider", "_login", "_password", "_host", "_port", "_params")
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
login: Optional[str] = None,
|
|
54
|
+
password: Optional[str] = None,
|
|
55
|
+
host: Optional[str] = None,
|
|
56
|
+
port: Optional[int] = None,
|
|
57
|
+
provider: Optional[Provider] = None,
|
|
58
|
+
**params: Any,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._provider = provider if provider is not None else load()
|
|
61
|
+
|
|
62
|
+
collision = sorted(self._provider.known_params & _RESERVED)
|
|
63
|
+
if collision:
|
|
64
|
+
raise ParamError(
|
|
65
|
+
f"provider {self._provider.label} names {collision} as gateway "
|
|
66
|
+
f"parameters, and this constructor already uses those names for "
|
|
67
|
+
f"credentials. Pass those settings through replace() on a "
|
|
68
|
+
f"provider whose definition renames them, or rename them with "
|
|
69
|
+
f"an alias."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
env = self._provider.id.upper().replace("-", "_")
|
|
73
|
+
self._login = login if login is not None else os.environ.get(f"{env}_LOGIN")
|
|
74
|
+
self._password = (
|
|
75
|
+
password if password is not None else os.environ.get(f"{env}_PASSWORD")
|
|
76
|
+
)
|
|
77
|
+
raw_host = host if host is not None else os.environ.get(f"{env}_HOST")
|
|
78
|
+
raw_port = port if port is not None else os.environ.get(f"{env}_PORT")
|
|
79
|
+
|
|
80
|
+
self._host = raw_host or self._provider.host
|
|
81
|
+
self._port = int(raw_port) if raw_port not in (None, "") else self._provider.port
|
|
82
|
+
|
|
83
|
+
if not self._login or not self._password:
|
|
84
|
+
missing = [
|
|
85
|
+
name
|
|
86
|
+
for name, value in (("login", self._login), ("password", self._password))
|
|
87
|
+
if not value
|
|
88
|
+
]
|
|
89
|
+
raise CredentialsError(
|
|
90
|
+
f"no {' and no '.join(missing)} for {self._provider.label}: pass "
|
|
91
|
+
f"it to Proxy() or set {env}_LOGIN and {env}_PASSWORD in the "
|
|
92
|
+
f"environment. Nothing was built, so nothing will connect."
|
|
93
|
+
)
|
|
94
|
+
if not self._host or not self._port:
|
|
95
|
+
raise CredentialsError(
|
|
96
|
+
f"no gateway address for {self._provider.label}: pass host= and "
|
|
97
|
+
f"port= or set {env}_HOST and {env}_PORT."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self._params = _validate(self._provider, params)
|
|
101
|
+
|
|
102
|
+
# -- what a client needs ------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def provider(self) -> Provider:
|
|
106
|
+
return self._provider
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def params(self) -> Dict[str, str]:
|
|
110
|
+
"""The gateway parameters, as they will be sent. A copy."""
|
|
111
|
+
return dict(self._params)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def server(self) -> str:
|
|
115
|
+
"""``host:port``, with no credentials in it."""
|
|
116
|
+
return f"{self._host}:{self._port}"
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def username(self) -> str:
|
|
120
|
+
"""The proxy username carrying every parameter, in the gateway's dialect."""
|
|
121
|
+
provider = self._provider
|
|
122
|
+
parts = [provider.prefix.format(login=self._login)]
|
|
123
|
+
for key, value in self._params.items():
|
|
124
|
+
parts.append(f"{provider.spell(key)}{provider.pair_separator}{value}")
|
|
125
|
+
return provider.separator.join(parts)
|
|
126
|
+
|
|
127
|
+
def url(self, scheme: str = "http") -> str:
|
|
128
|
+
"""``http://user:pass@host:port``, for requests, httpx, curl and aiohttp.
|
|
129
|
+
|
|
130
|
+
Both credentials are percent-encoded with an empty ``safe`` set. A URL
|
|
131
|
+
has structure, and the structure is made of the characters ``: @ / ? #``;
|
|
132
|
+
a password containing any of them cuts the string in the wrong place and
|
|
133
|
+
the client connects somewhere else entirely. ``pa/ss`` unencoded ends the
|
|
134
|
+
authority at the slash, so the host becomes ``pa``.
|
|
135
|
+
|
|
136
|
+
That failure is worse than an exception, because at least one browser
|
|
137
|
+
engine answers a proxy URL it cannot parse by connecting **directly**
|
|
138
|
+
without reporting it - the request then leaves from your own address
|
|
139
|
+
while everything downstream believes it went through the pool.
|
|
140
|
+
|
|
141
|
+
Note the asymmetry with :meth:`playwright`, which must not encode:
|
|
142
|
+
there the fields are passed separately and the library encodes them
|
|
143
|
+
itself, so encoding here as well turns ``pa/ss`` into ``pa%252Fss`` and
|
|
144
|
+
authentication fails while blaming the credentials.
|
|
145
|
+
"""
|
|
146
|
+
user = quote(self.username, safe="")
|
|
147
|
+
secret = quote(self._password, safe="")
|
|
148
|
+
return f"{scheme}://{user}:{secret}@{self.server}"
|
|
149
|
+
|
|
150
|
+
def requests(self, scheme: str = "http") -> Dict[str, str]:
|
|
151
|
+
"""A ``proxies=`` mapping for requests. Also works for aiohttp's ``proxy=``
|
|
152
|
+
via :meth:`url`."""
|
|
153
|
+
url = self.url(scheme)
|
|
154
|
+
return {"http": url, "https": url}
|
|
155
|
+
|
|
156
|
+
def httpx(self, scheme: str = "http") -> Dict[str, str]:
|
|
157
|
+
"""A ``mounts``-style mapping for httpx. Modern httpx also takes
|
|
158
|
+
``proxy=proxy.url()`` directly."""
|
|
159
|
+
url = self.url(scheme)
|
|
160
|
+
return {"http://": url, "https://": url}
|
|
161
|
+
|
|
162
|
+
def playwright(self) -> Dict[str, str]:
|
|
163
|
+
"""A ``proxy=`` dict for Playwright, Patchright and Puppeteer.
|
|
164
|
+
|
|
165
|
+
Deliberately not percent-encoded - see :meth:`url`.
|
|
166
|
+
"""
|
|
167
|
+
return {
|
|
168
|
+
"server": f"http://{self.server}",
|
|
169
|
+
"username": self.username,
|
|
170
|
+
"password": self._password,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# -- moving deliberately ------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def replace(self, **changes: Any) -> "Proxy":
|
|
176
|
+
"""A new identity with parameters added, changed, or removed with ``None``.
|
|
177
|
+
|
|
178
|
+
This returns a new object rather than mutating, because on a gateway
|
|
179
|
+
whose session key is the whole parameter set, changing a parameter is
|
|
180
|
+
not an adjustment to one identity - it is a different identity on a
|
|
181
|
+
different exit address. A method that returned ``self`` would hide that.
|
|
182
|
+
"""
|
|
183
|
+
params: Dict[str, Any] = dict(self._params)
|
|
184
|
+
for key, value in changes.items():
|
|
185
|
+
if value is None:
|
|
186
|
+
params.pop(key, None)
|
|
187
|
+
else:
|
|
188
|
+
params[key] = value
|
|
189
|
+
return Proxy(
|
|
190
|
+
login=self._login,
|
|
191
|
+
password=self._password,
|
|
192
|
+
host=self._host,
|
|
193
|
+
port=self._port,
|
|
194
|
+
provider=self._provider,
|
|
195
|
+
**params,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def session(self, session_id: str) -> "Proxy":
|
|
199
|
+
"""The same parameters, pinned to one sticky session.
|
|
200
|
+
|
|
201
|
+
A provider that declares no session parameter gets none, and the id is
|
|
202
|
+
dropped rather than sent under a guessed name: a name this gateway does
|
|
203
|
+
not know is answered with 200 and ignored, so every request would draw a
|
|
204
|
+
fresh exit while your code believed it was holding one.
|
|
205
|
+
"""
|
|
206
|
+
if not self._provider.session_param:
|
|
207
|
+
raise ParamError(
|
|
208
|
+
f"{self._provider.label} declares no session parameter, so "
|
|
209
|
+
f"{session_id!r} cannot be sent and every request will leave "
|
|
210
|
+
f"from whichever exit the gateway picks."
|
|
211
|
+
)
|
|
212
|
+
return self.replace(**{self._provider.session_param: session_id})
|
|
213
|
+
|
|
214
|
+
# -- output that is not a credential ------------------------------------
|
|
215
|
+
|
|
216
|
+
def __repr__(self) -> str:
|
|
217
|
+
"""Never carries the password.
|
|
218
|
+
|
|
219
|
+
The default repr of a container calls ``__repr__`` on its elements, and
|
|
220
|
+
tracebacks, ``pytest --showlocals`` and most logging setups print reprs
|
|
221
|
+
of locals. A password in here reaches CI logs and pasted bug reports
|
|
222
|
+
without anyone ever choosing to print it. :meth:`url` is the one place
|
|
223
|
+
the secret appears, and it is named so that it is obvious.
|
|
224
|
+
"""
|
|
225
|
+
shown = "".join(f", {k}={v!r}" for k, v in self._params.items())
|
|
226
|
+
return (
|
|
227
|
+
f"Proxy(provider={self._provider.id!r}, server={self.server!r}, "
|
|
228
|
+
f"login={self._login!r}, password={_REDACTED!r}{shown})"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate(provider: Provider, params: Mapping[str, Any]) -> Dict[str, str]:
|
|
233
|
+
"""Refuse client-side what the gateway will not report.
|
|
234
|
+
|
|
235
|
+
This is not politeness, it is the only check available. The gateway this
|
|
236
|
+
package ships a definition for answers seven kinds of bad input seven
|
|
237
|
+
different ways and none of them names the cause: a bad country or region
|
|
238
|
+
gives 406, a bad city 500, a bad filter or ttl value gives 407 - which sends
|
|
239
|
+
you to check credentials that are fine - an empty value hangs the connection
|
|
240
|
+
for about twenty seconds, and an unknown parameter name is answered with
|
|
241
|
+
**200 and the setting silently dropped**. That last one is why this function
|
|
242
|
+
exists: the request succeeds, and nothing that comes back can tell you the
|
|
243
|
+
setting was never applied.
|
|
244
|
+
"""
|
|
245
|
+
out: Dict[str, str] = {}
|
|
246
|
+
for key, value in params.items():
|
|
247
|
+
if key not in provider.known_params:
|
|
248
|
+
raise ParamError(
|
|
249
|
+
f"{provider.label} does not know the parameter {key!r}: it is "
|
|
250
|
+
f"answered with 200 and dropped, so the connection would succeed "
|
|
251
|
+
f"and your setting would NOT be applied. "
|
|
252
|
+
f"Known: {sorted(provider.known_params)}"
|
|
253
|
+
)
|
|
254
|
+
if value is None or value == "":
|
|
255
|
+
raise ParamError(
|
|
256
|
+
f"empty value for {key!r}: the gateway does not reply to this, "
|
|
257
|
+
f"the connection hangs for about 20 s and then fails. Drop the "
|
|
258
|
+
f"parameter instead of passing an empty value."
|
|
259
|
+
)
|
|
260
|
+
if isinstance(value, bool):
|
|
261
|
+
value = "true" if value else "false"
|
|
262
|
+
text = str(value)
|
|
263
|
+
separators = sorted({provider.separator, provider.pair_separator} - {""})
|
|
264
|
+
bad = sorted({c for c in separators if c in text})
|
|
265
|
+
if bad:
|
|
266
|
+
raise ParamError(
|
|
267
|
+
f"the value of {key!r} is {text!r} and contains {bad}, which "
|
|
268
|
+
f"{provider.label} uses to separate parameters. The username "
|
|
269
|
+
f"would be split into different settings than you asked for. "
|
|
270
|
+
f"Session ids in particular cannot carry a {bad[0]!r} - use "
|
|
271
|
+
f"{text.replace(bad[0], '')!r} or another character."
|
|
272
|
+
)
|
|
273
|
+
out[key] = text
|
|
274
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodemaven
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and validate proxy connection strings. No transport, no retries.
|
|
5
|
+
Author-email: NodeMaven <alexandr.kazmin@nodemaven.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nodemaven.com
|
|
8
|
+
Keywords: proxy,residential-proxy,scraping,http-proxy,playwright
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# nodemaven
|
|
27
|
+
|
|
28
|
+
Build and validate proxy connection strings.
|
|
29
|
+
|
|
30
|
+
This library opens no socket. It builds the username a proxy gateway expects,
|
|
31
|
+
refuses the input that gateway would mishandle, and hands the result to whatever
|
|
32
|
+
HTTP client you already use.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install nodemaven
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import requests
|
|
42
|
+
from nodemaven import Proxy
|
|
43
|
+
|
|
44
|
+
proxy = Proxy(login="your-login", password="your-password",
|
|
45
|
+
country="us", filter="medium")
|
|
46
|
+
|
|
47
|
+
r = requests.get("https://api.ipify.org", proxies=proxy.requests())
|
|
48
|
+
print(r.text)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Credentials can come from the environment instead, so nothing is in your source:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# NODEMAVEN_LOGIN and NODEMAVEN_PASSWORD
|
|
55
|
+
proxy = Proxy(country="us", filter="medium")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The same identity, for other clients:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
proxy.url() # http://user:pass@gate.nodemaven.com:8080 - httpx, aiohttp, curl
|
|
62
|
+
proxy.requests() # {"http": ..., "https": ...}
|
|
63
|
+
proxy.httpx() # {"http://": ..., "https://": ...}
|
|
64
|
+
proxy.playwright() # {"server": ..., "username": ..., "password": ...}
|
|
65
|
+
proxy.username # the username on its own
|
|
66
|
+
proxy.server # host:port, no credentials
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With Playwright, Patchright or Puppeteer:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from playwright.sync_api import sync_playwright
|
|
73
|
+
|
|
74
|
+
with sync_playwright() as p:
|
|
75
|
+
browser = p.chromium.launch()
|
|
76
|
+
context = browser.new_context(proxy=proxy.playwright())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Sticky sessions
|
|
80
|
+
|
|
81
|
+
One `Proxy` is one identity. Pin it to a sticky session:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
held = proxy.session("order-4417")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**The session key is the whole parameter set, not the session id.**
|
|
88
|
+
`country=us, sid=A` and `country=us, sid=A, filter=medium` are two different
|
|
89
|
+
sessions on the gateway, so adding or removing any parameter moves you to a
|
|
90
|
+
different exit address. That is why parameters change through a method that
|
|
91
|
+
returns a new object rather than by assignment - the move is a different
|
|
92
|
+
identity, and the code should say so:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
germany = proxy.replace(country="de") # a new identity, a new exit
|
|
96
|
+
plain = proxy.replace(filter=None) # also a new identity
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Why the validation is the point
|
|
100
|
+
|
|
101
|
+
A gateway is bad at telling you that you got the username wrong. Measured
|
|
102
|
+
against this one on 2026-08-10, seven kinds of bad input produce seven
|
|
103
|
+
different reactions and not one of them names the cause:
|
|
104
|
+
|
|
105
|
+
| you sent | the gateway answers |
|
|
106
|
+
|---|---|
|
|
107
|
+
| bad country | `406 Not Acceptable` |
|
|
108
|
+
| bad region | `406 Not Acceptable` |
|
|
109
|
+
| bad city | `500 Internal Server Error` |
|
|
110
|
+
| bad `filter` value | `407 Proxy Authentication Required` |
|
|
111
|
+
| bad `ttl` value | `407 Proxy Authentication Required` |
|
|
112
|
+
| empty value | nothing, the connection hangs about 20 s |
|
|
113
|
+
| **unknown parameter name** | **`200`, and the parameter is ignored** |
|
|
114
|
+
|
|
115
|
+
The two `407` replies send you to check credentials that are correct. The last
|
|
116
|
+
row is worse than any of them: the request succeeds, your code carries on, and
|
|
117
|
+
the setting you asked for was never applied. Nothing that comes back over the
|
|
118
|
+
wire can tell you.
|
|
119
|
+
|
|
120
|
+
So this library checks before anything is sent:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
>>> Proxy(login="u", password="p", contry="us")
|
|
124
|
+
ParamError: NodeMaven does not know the parameter 'contry': it is answered with
|
|
125
|
+
200 and dropped, so the connection would succeed and your setting would NOT be
|
|
126
|
+
applied. Known: ['city', 'country', 'filter', 'ipv4', 'isp', 'region', 'sid',
|
|
127
|
+
'speed', 'ttl']
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## What this library does not do
|
|
131
|
+
|
|
132
|
+
**It does not retry.** That is deliberate, and it is the one design decision
|
|
133
|
+
here taken against a measurement rather than a preference.
|
|
134
|
+
|
|
135
|
+
Retrying a refused request is the thing that most reliably makes the next one
|
|
136
|
+
worse: each retry confirms automation to the target and burns the exit range for
|
|
137
|
+
everyone else sharing the pool. Measured over 1464 attempts, the chance that the
|
|
138
|
+
next attempt succeeds, by how many failures came immediately before it:
|
|
139
|
+
|
|
140
|
+
| failures before | P(next attempt succeeds) |
|
|
141
|
+
|---|---|
|
|
142
|
+
| 0 | 75% |
|
|
143
|
+
| 1 | 21% |
|
|
144
|
+
| 3 | 5.9% |
|
|
145
|
+
| 5 | 5.8% |
|
|
146
|
+
| 6 | 1.6% |
|
|
147
|
+
| 7-9 | 0.5% |
|
|
148
|
+
|
|
149
|
+
294 attempts were spent past six consecutive failures and returned 3 pages - 98
|
|
150
|
+
attempts per delivered page, against 1.7 in a healthy session. A library that
|
|
151
|
+
shipped automatic retry as a default would be spending that on your behalf
|
|
152
|
+
without telling you.
|
|
153
|
+
|
|
154
|
+
It also does not own an HTTP client, a connection pool or a browser. Those are
|
|
155
|
+
yours, and they are better than anything a vendor SDK would bundle.
|
|
156
|
+
|
|
157
|
+
## Other gateways
|
|
158
|
+
|
|
159
|
+
Parameters are data, not hardcoded keywords. A gateway is one TOML file - its
|
|
160
|
+
prefix, separators, session parameter and the set of parameter names it actually
|
|
161
|
+
recognises - and a definition read from disk goes through the same builder and
|
|
162
|
+
the same validation as the one shipped here:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from nodemaven import Proxy, load_file
|
|
166
|
+
|
|
167
|
+
mine = load_file("my-gateway.toml")
|
|
168
|
+
proxy = Proxy(provider=mine, login="u", password="p", country="us")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every definition carries a `status`. `measured` means traffic has gone through
|
|
172
|
+
that gateway and the dialect was read off the wire. `documented` means it was
|
|
173
|
+
transcribed from documentation and never exercised. Only `nodemaven` is shipped
|
|
174
|
+
here, and it is `measured`.
|
|
175
|
+
|
|
176
|
+
## Requirements
|
|
177
|
+
|
|
178
|
+
Python 3.9 or newer. No dependencies on 3.11 and newer; `tomli` on older ones.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nodemaven/__init__.py
|
|
5
|
+
src/nodemaven/errors.py
|
|
6
|
+
src/nodemaven/providers.py
|
|
7
|
+
src/nodemaven/proxy.py
|
|
8
|
+
src/nodemaven/py.typed
|
|
9
|
+
src/nodemaven.egg-info/PKG-INFO
|
|
10
|
+
src/nodemaven.egg-info/SOURCES.txt
|
|
11
|
+
src/nodemaven.egg-info/dependency_links.txt
|
|
12
|
+
src/nodemaven.egg-info/requires.txt
|
|
13
|
+
src/nodemaven.egg-info/top_level.txt
|
|
14
|
+
src/nodemaven/data/providers/nodemaven.toml
|
|
15
|
+
tests/test_proxy.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodemaven
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Offline in full: no network, no credentials, no gateway.
|
|
2
|
+
|
|
3
|
+
These cases are the seed of the golden vectors the other language SDKs will run.
|
|
4
|
+
Anything asserted here is a statement about what a correct username is, not about
|
|
5
|
+
how this implementation happens to be written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from nodemaven import ParamError, Proxy, available, load
|
|
13
|
+
|
|
14
|
+
CREDS = {"login": "acct", "password": "pw", "host": "gate.example.com", "port": 8080}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def no_gateway_env(monkeypatch):
|
|
19
|
+
"""A test that reached for real credentials would spend real traffic."""
|
|
20
|
+
for name in ("LOGIN", "PASSWORD", "HOST", "PORT"):
|
|
21
|
+
monkeypatch.delenv(f"NODEMAVEN_{name}", raising=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestTheUsername:
|
|
25
|
+
def test_parameters_are_spelled_in_the_gateway_dialect(self):
|
|
26
|
+
proxy = Proxy(country="us", filter="medium", sid="abc123", **CREDS)
|
|
27
|
+
assert proxy.username == "acct-country-us-filter-medium-sid-abc123"
|
|
28
|
+
|
|
29
|
+
def test_no_parameters_is_the_bare_login(self):
|
|
30
|
+
assert Proxy(**CREDS).username == "acct"
|
|
31
|
+
|
|
32
|
+
def test_order_follows_the_call(self):
|
|
33
|
+
a = Proxy(country="us", sid="x", **CREDS).username
|
|
34
|
+
b = Proxy(sid="x", country="us", **CREDS).username
|
|
35
|
+
assert a == "acct-country-us-sid-x"
|
|
36
|
+
assert b == "acct-sid-x-country-us"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestValidationTheGatewayCannotDo:
|
|
40
|
+
def test_an_unknown_name_is_refused_before_anything_is_sent(self):
|
|
41
|
+
# The gateway answers this with 200 and drops the setting, so the run
|
|
42
|
+
# completes claiming a setting that was never applied.
|
|
43
|
+
with pytest.raises(ParamError, match="does not know the parameter"):
|
|
44
|
+
Proxy(contry="us", **CREDS)
|
|
45
|
+
|
|
46
|
+
def test_an_empty_value_is_refused(self):
|
|
47
|
+
# The gateway does not reply at all; the connection hangs about 20 s.
|
|
48
|
+
with pytest.raises(ParamError, match="empty value"):
|
|
49
|
+
Proxy(country="", **CREDS)
|
|
50
|
+
|
|
51
|
+
def test_a_separator_inside_a_value_is_refused(self):
|
|
52
|
+
# "us-east" would be parsed as country=us plus a parameter named east.
|
|
53
|
+
with pytest.raises(ParamError, match="separate parameters"):
|
|
54
|
+
Proxy(country="us-east", **CREDS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestTheUrlIsSafeToHandToAClient:
|
|
58
|
+
def test_credentials_are_percent_encoded(self):
|
|
59
|
+
proxy = Proxy(country="us", **{**CREDS, "password": "pa/ss@1:2"})
|
|
60
|
+
assert proxy.url() == (
|
|
61
|
+
"http://acct-country-us:pa%2Fss%401%3A2@gate.example.com:8080"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def test_the_slash_is_encoded_and_that_is_the_whole_point(self):
|
|
65
|
+
# quote() defaults to safe="/", which leaves the one character that ends
|
|
66
|
+
# the authority. Unencoded, the host below becomes "pa".
|
|
67
|
+
proxy = Proxy(**{**CREDS, "password": "pa/ss"})
|
|
68
|
+
assert "%2F" in proxy.url()
|
|
69
|
+
assert proxy.url().endswith("@gate.example.com:8080")
|
|
70
|
+
|
|
71
|
+
def test_the_playwright_dict_is_not_encoded(self):
|
|
72
|
+
# Playwright encodes the fields itself; encoding here too would send
|
|
73
|
+
# pa%252Fss and fail authentication while blaming the credentials.
|
|
74
|
+
proxy = Proxy(**{**CREDS, "password": "pa/ss"})
|
|
75
|
+
assert proxy.playwright()["password"] == "pa/ss"
|
|
76
|
+
assert proxy.playwright()["server"] == "http://gate.example.com:8080"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestTheReprCarriesNoSecret:
|
|
80
|
+
def test_the_password_is_redacted(self):
|
|
81
|
+
proxy = Proxy(country="us", **{**CREDS, "password": "hunter2"})
|
|
82
|
+
assert "hunter2" not in repr(proxy)
|
|
83
|
+
assert "***" in repr(proxy)
|
|
84
|
+
|
|
85
|
+
def test_it_survives_being_put_in_a_container(self):
|
|
86
|
+
# A container's __str__ calls __repr__ on its elements, which is how a
|
|
87
|
+
# careful __str__ gets bypassed in a log line.
|
|
88
|
+
proxy = Proxy(**{**CREDS, "password": "hunter2"})
|
|
89
|
+
assert "hunter2" not in str([proxy])
|
|
90
|
+
assert "hunter2" not in str({"p": proxy})
|
|
91
|
+
|
|
92
|
+
def test_the_parameters_are_still_visible(self):
|
|
93
|
+
assert "country='us'" in repr(Proxy(country="us", **CREDS))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestMovingIsANewIdentity:
|
|
97
|
+
def test_replace_returns_a_new_object(self):
|
|
98
|
+
first = Proxy(country="us", **CREDS)
|
|
99
|
+
second = first.replace(country="de")
|
|
100
|
+
assert first.username == "acct-country-us"
|
|
101
|
+
assert second.username == "acct-country-de"
|
|
102
|
+
assert first is not second
|
|
103
|
+
|
|
104
|
+
def test_none_removes_a_parameter(self):
|
|
105
|
+
proxy = Proxy(country="us", filter="medium", **CREDS).replace(filter=None)
|
|
106
|
+
assert proxy.username == "acct-country-us"
|
|
107
|
+
|
|
108
|
+
def test_session_uses_the_providers_own_name_for_it(self):
|
|
109
|
+
proxy = Proxy(country="us", **CREDS).session("order4417")
|
|
110
|
+
assert proxy.username == "acct-country-us-sid-order4417"
|
|
111
|
+
|
|
112
|
+
def test_a_session_id_carrying_the_separator_is_refused(self):
|
|
113
|
+
# `order-4417` is the obvious thing to use as a session key and this
|
|
114
|
+
# dialect cannot carry it: separator and pair separator are both "-",
|
|
115
|
+
# so `sid-order-4417` reads as sid=order followed by a parameter named
|
|
116
|
+
# 4417. Refusing is inference from the dialect rather than a measured
|
|
117
|
+
# gateway reply - see the open question in CLAUDE.md - and it errs
|
|
118
|
+
# towards a loud error over a silently different exit.
|
|
119
|
+
with pytest.raises(ParamError, match="cannot carry"):
|
|
120
|
+
Proxy(country="us", **CREDS).session("order-4417")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestTheShippedDefinition:
|
|
124
|
+
def test_nodemaven_is_shipped_and_is_measured(self):
|
|
125
|
+
assert "nodemaven" in available()
|
|
126
|
+
assert load("nodemaven").is_measured
|
|
127
|
+
|
|
128
|
+
def test_the_session_parameter_is_asked_for_rather_than_spelled(self):
|
|
129
|
+
# Eleven call sites in the benchmark wrote "sid" directly. It is the name
|
|
130
|
+
# this gateway happens to use, which is exactly why the literal survived.
|
|
131
|
+
assert load("nodemaven").session_param == "sid"
|