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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+
2
+ [:python_version < "3.11"]
3
+ tomli>=1.1.0
@@ -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"