sherlock-proxyhat 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,60 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ lint:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install ruff
19
+ - run: ruff check src/ tests/
20
+ - run: ruff format --check src/ tests/
21
+
22
+ test:
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ matrix:
26
+ python-version: ["3.11", "3.12", "3.13"]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+ # Tests fake the `sherlock` executable and subprocess, so [dev] alone
33
+ # (no sherlock-project install) is enough.
34
+ - run: pip install -e ".[dev]"
35
+ - run: pytest -v
36
+
37
+ build:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: actions/setup-python@v5
42
+ with:
43
+ python-version: "3.12"
44
+ - run: pip install build
45
+ - run: python -m build
46
+
47
+ publish:
48
+ needs: [lint, test, build]
49
+ runs-on: ubuntu-latest
50
+ if: startsWith(github.ref, 'refs/tags/v')
51
+ permissions:
52
+ id-token: write
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: actions/setup-python@v5
56
+ with:
57
+ python-version: "3.12"
58
+ - run: pip install build
59
+ - run: python -m build
60
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,58 @@
1
+ name: Compat (sherlock-project latest)
2
+
3
+ # Autonomy watchdog: weekly (and on demand) it installs the newest sherlock-project
4
+ # and our newest proxyhat SDK, then checks that Sherlock still exposes the `--proxy`
5
+ # flag this launcher depends on, and runs our tests. A red run means upstream
6
+ # changed the proxy CLI surface — it opens/updates a tracking issue so we notice
7
+ # before users do. The proactive path is Renovate (renovate.json), which opens a
8
+ # version-bump PR that must pass CI before merging.
9
+ on:
10
+ schedule:
11
+ - cron: "0 6 * * 1" # Mondays 06:00 UTC
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+ issues: write
17
+
18
+ jobs:
19
+ compat:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+ - run: pip install -e ".[dev]"
27
+ - name: Install latest upstreams
28
+ run: pip install --upgrade sherlock-project proxyhat
29
+ - name: Assert Sherlock still accepts --proxy
30
+ # This launcher forwards a residential proxy URL via `sherlock --proxy <url>`.
31
+ # Fail loudly if that flag ever disappears or is renamed.
32
+ run: |
33
+ sherlock --help 2>&1 | grep -- '--proxy' \
34
+ || { echo "sherlock no longer advertises a --proxy flag"; exit 1; }
35
+ echo "sherlock --proxy surface OK"
36
+ - run: pytest -v
37
+ - name: Open an issue on failure
38
+ if: failure()
39
+ uses: actions/github-script@v7
40
+ with:
41
+ script: |
42
+ const title = "Compat break: sherlock-project@latest / proxyhat@latest";
43
+ const { data: issues } = await github.rest.issues.listForRepo({
44
+ owner: context.repo.owner, repo: context.repo.repo,
45
+ state: "open", labels: "compat",
46
+ });
47
+ const body = `The weekly compatibility check failed against the latest upstream releases.\n\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
48
+ if (issues.length === 0) {
49
+ await github.rest.issues.create({
50
+ owner: context.repo.owner, repo: context.repo.repo,
51
+ title, body, labels: ["compat"],
52
+ });
53
+ } else {
54
+ await github.rest.issues.createComment({
55
+ owner: context.repo.owner, repo: context.repo.repo,
56
+ issue_number: issues[0].number, body,
57
+ });
58
+ }
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ProxyHat
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,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: sherlock-proxyhat
3
+ Version: 0.1.0
4
+ Summary: Run the Sherlock OSINT username hunter through ProxyHat residential proxies — rotating IPs, geo-targeting, fewer blocks.
5
+ Project-URL: Homepage, https://proxyhat.com
6
+ Project-URL: Documentation, https://docs.proxyhat.com
7
+ Project-URL: Repository, https://github.com/ProxyHatCom/sherlock-proxyhat
8
+ Author-email: ProxyHat <support@proxyhat.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: osint,proxy,proxyhat,reconnaissance,residential-proxy,rotating-proxy,sherlock,username
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet :: Proxy Servers
22
+ Classifier: Topic :: Security
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: proxyhat>=0.2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Provides-Extra: sherlock
29
+ Requires-Dist: sherlock-project>=0.14; extra == 'sherlock'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # sherlock-proxyhat
33
+
34
+ Run [Sherlock](https://github.com/sherlock-project/sherlock) — the OSINT username hunter — through [ProxyHat](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=sherlock) residential proxies. Rotating residential IPs by default (a fresh IP per connection across the hundreds of sites Sherlock checks), plus geo-targeting and anonymity, so you get rate-limited and blocked far less.
35
+
36
+ [![CI](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/ci.yml/badge.svg)](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/ci.yml)
37
+ [![Compatible with sherlock latest](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/compat.yml/badge.svg)](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/compat.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/sherlock-proxyhat)](https://pypi.org/project/sherlock-proxyhat/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ ## What this is
42
+
43
+ Sherlock is an **app**, not a library: you run `sherlock <username>` and it checks that name across 400+ sites, accepting a single `--proxy <url>` for all of them. Hammering that many sites from one datacenter IP gets throttled and blocked fast.
44
+
45
+ `sherlock-proxyhat` is a **thin launcher**. It resolves a ProxyHat residential gateway URL, then runs the real `sherlock` executable with `--proxy <url>` prepended — every other argument (usernames and Sherlock's own flags) passes straight through. No fork, no patched Sherlock: it just wires ProxyHat into the `--proxy` flag Sherlock already has.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install sherlock-proxyhat sherlock-project
51
+ ```
52
+
53
+ `sherlock-project` (which provides the `sherlock` command) is an optional dependency — this launcher shells out to the `sherlock` executable, so any install works (pip, pipx, system package). To pull it in with the launcher:
54
+
55
+ ```bash
56
+ pip install "sherlock-proxyhat[sherlock]"
57
+ ```
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ # An API key auto-selects an active residential sub-user:
63
+ export PROXYHAT_API_KEY=ph_xxx
64
+
65
+ # Same arguments as sherlock — just run it through the launcher:
66
+ sherlock-proxyhat johndoe
67
+
68
+ # Geo-target and add sherlock's own flags; everything after is passed through:
69
+ sherlock-proxyhat --proxyhat-country us johndoe janedoe --timeout 30 --csv
70
+ ```
71
+
72
+ Get an API key at [proxyhat.com](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=sherlock).
73
+
74
+ Anything that isn't a `--proxyhat-*` option is forwarded to `sherlock` verbatim, so every Sherlock flag (`--timeout`, `--site`, `--nsfw`, `--csv`, `-o`, …) works unchanged.
75
+
76
+ ## Credentials
77
+
78
+ Pass them as `--proxyhat-*` flags or via environment variables — flags win over env:
79
+
80
+ | Flag | Env var | Notes |
81
+ |---|---|---|
82
+ | `--proxyhat-api-key` | `PROXYHAT_API_KEY` | Auto-selects an active sub-user with remaining traffic |
83
+ | `--proxyhat-sub-user` | `PROXYHAT_SUBUSER` | Pick a specific sub-user by uuid or name (with an API key) |
84
+ | `--proxyhat-username` | `PROXYHAT_USERNAME` | Explicit gateway `proxy_username` (skips the API) |
85
+ | `--proxyhat-password` | `PROXYHAT_PASSWORD` | Explicit gateway `proxy_password` |
86
+
87
+ ## Targeting
88
+
89
+ All targeting lives on `--proxyhat-*` flags, which are stripped before the command reaches `sherlock`:
90
+
91
+ ```bash
92
+ sherlock-proxyhat \
93
+ --proxyhat-protocol http \ # or socks5
94
+ --proxyhat-country us \ # ISO code or "any" (default)
95
+ --proxyhat-region california \
96
+ --proxyhat-city los_angeles \
97
+ --proxyhat-filter high \ # AI IP-quality tier
98
+ johndoe
99
+ ```
100
+
101
+ ### Rotating IPs by default
102
+
103
+ Sherlock reuses one proxy for the whole run, so this launcher builds a **rotating** gateway username by default: the ProxyHat gateway hands out a fresh residential IP on every connection, spreading the hundreds of per-site checks across many IPs — exactly what you want to dodge per-IP rate limits during a scan.
104
+
105
+ Want to pin a single IP for the whole run instead? Add `--proxyhat-sticky` (optionally with a lifetime via `--proxyhat-sticky-ttl`):
106
+
107
+ ```bash
108
+ sherlock-proxyhat --proxyhat-sticky johndoe # one pinned IP, 30m default
109
+ sherlock-proxyhat --proxyhat-sticky-ttl 2h johndoe # one pinned IP, 2h lifetime
110
+ ```
111
+
112
+ ### Bring your own proxy
113
+
114
+ If you pass Sherlock's own `--proxy`/`-p` (or `--tor`) yourself, the launcher steps aside: it forwards your arguments unchanged and does not inject a ProxyHat URL.
115
+
116
+ ## Using the URL builder directly
117
+
118
+ Need the gateway URL in your own script or a different tool? Import the helper:
119
+
120
+ ```python
121
+ from sherlock_proxyhat import proxyhat_proxy_url
122
+
123
+ url = proxyhat_proxy_url(api_key="ph_xxx", country="us")
124
+ # -> "http://<user>-country-us:<pass>@gate.proxyhat.com:8080"
125
+ ```
126
+
127
+ Rotating by default (`sticky=None`); pass `sticky="30m"` (or `True`) to pin one IP.
128
+
129
+ ## How it works
130
+
131
+ The launcher parses out its `--proxyhat-*` options, resolves your gateway credentials via the official [`proxyhat`](https://pypi.org/project/proxyhat/) SDK (an API key auto-picks an active sub-user, or pass `username`/`password`), and builds a connection URL carrying ProxyHat's targeting grammar (`http://<user>-country-us:<pass>@gate.proxyhat.com:8080`, or `socks5://…:1080`). It then finds the `sherlock` executable on your `PATH` and runs `sherlock --proxy <url> <your other args>` as a subprocess, returning Sherlock's exit code. A rotating username makes the gateway hand out a fresh residential IP per connection; `--proxyhat-sticky` pins one.
132
+
133
+ ## Roadmap
134
+
135
+ This launcher wraps Sherlock's existing `--proxy` flag on purpose — it's the honest, low-friction way to ship today. A **planned follow-up** is an upstream PR to Sherlock adding a native `--proxyhat` flag (resolve credentials and geo-target from within Sherlock itself, no wrapper), so ProxyHat becomes a first-class option in the tool. Until that lands, this package is the supported path.
136
+
137
+ ## License
138
+
139
+ MIT © [ProxyHat](https://proxyhat.com)
@@ -0,0 +1,108 @@
1
+ # sherlock-proxyhat
2
+
3
+ Run [Sherlock](https://github.com/sherlock-project/sherlock) — the OSINT username hunter — through [ProxyHat](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=sherlock) residential proxies. Rotating residential IPs by default (a fresh IP per connection across the hundreds of sites Sherlock checks), plus geo-targeting and anonymity, so you get rate-limited and blocked far less.
4
+
5
+ [![CI](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/ci.yml/badge.svg)](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/ci.yml)
6
+ [![Compatible with sherlock latest](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/compat.yml/badge.svg)](https://github.com/ProxyHatCom/sherlock-proxyhat/actions/workflows/compat.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/sherlock-proxyhat)](https://pypi.org/project/sherlock-proxyhat/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
+
10
+ ## What this is
11
+
12
+ Sherlock is an **app**, not a library: you run `sherlock <username>` and it checks that name across 400+ sites, accepting a single `--proxy <url>` for all of them. Hammering that many sites from one datacenter IP gets throttled and blocked fast.
13
+
14
+ `sherlock-proxyhat` is a **thin launcher**. It resolves a ProxyHat residential gateway URL, then runs the real `sherlock` executable with `--proxy <url>` prepended — every other argument (usernames and Sherlock's own flags) passes straight through. No fork, no patched Sherlock: it just wires ProxyHat into the `--proxy` flag Sherlock already has.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install sherlock-proxyhat sherlock-project
20
+ ```
21
+
22
+ `sherlock-project` (which provides the `sherlock` command) is an optional dependency — this launcher shells out to the `sherlock` executable, so any install works (pip, pipx, system package). To pull it in with the launcher:
23
+
24
+ ```bash
25
+ pip install "sherlock-proxyhat[sherlock]"
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```bash
31
+ # An API key auto-selects an active residential sub-user:
32
+ export PROXYHAT_API_KEY=ph_xxx
33
+
34
+ # Same arguments as sherlock — just run it through the launcher:
35
+ sherlock-proxyhat johndoe
36
+
37
+ # Geo-target and add sherlock's own flags; everything after is passed through:
38
+ sherlock-proxyhat --proxyhat-country us johndoe janedoe --timeout 30 --csv
39
+ ```
40
+
41
+ Get an API key at [proxyhat.com](https://proxyhat.com?utm_source=github&utm_medium=readme&utm_campaign=sherlock).
42
+
43
+ Anything that isn't a `--proxyhat-*` option is forwarded to `sherlock` verbatim, so every Sherlock flag (`--timeout`, `--site`, `--nsfw`, `--csv`, `-o`, …) works unchanged.
44
+
45
+ ## Credentials
46
+
47
+ Pass them as `--proxyhat-*` flags or via environment variables — flags win over env:
48
+
49
+ | Flag | Env var | Notes |
50
+ |---|---|---|
51
+ | `--proxyhat-api-key` | `PROXYHAT_API_KEY` | Auto-selects an active sub-user with remaining traffic |
52
+ | `--proxyhat-sub-user` | `PROXYHAT_SUBUSER` | Pick a specific sub-user by uuid or name (with an API key) |
53
+ | `--proxyhat-username` | `PROXYHAT_USERNAME` | Explicit gateway `proxy_username` (skips the API) |
54
+ | `--proxyhat-password` | `PROXYHAT_PASSWORD` | Explicit gateway `proxy_password` |
55
+
56
+ ## Targeting
57
+
58
+ All targeting lives on `--proxyhat-*` flags, which are stripped before the command reaches `sherlock`:
59
+
60
+ ```bash
61
+ sherlock-proxyhat \
62
+ --proxyhat-protocol http \ # or socks5
63
+ --proxyhat-country us \ # ISO code or "any" (default)
64
+ --proxyhat-region california \
65
+ --proxyhat-city los_angeles \
66
+ --proxyhat-filter high \ # AI IP-quality tier
67
+ johndoe
68
+ ```
69
+
70
+ ### Rotating IPs by default
71
+
72
+ Sherlock reuses one proxy for the whole run, so this launcher builds a **rotating** gateway username by default: the ProxyHat gateway hands out a fresh residential IP on every connection, spreading the hundreds of per-site checks across many IPs — exactly what you want to dodge per-IP rate limits during a scan.
73
+
74
+ Want to pin a single IP for the whole run instead? Add `--proxyhat-sticky` (optionally with a lifetime via `--proxyhat-sticky-ttl`):
75
+
76
+ ```bash
77
+ sherlock-proxyhat --proxyhat-sticky johndoe # one pinned IP, 30m default
78
+ sherlock-proxyhat --proxyhat-sticky-ttl 2h johndoe # one pinned IP, 2h lifetime
79
+ ```
80
+
81
+ ### Bring your own proxy
82
+
83
+ If you pass Sherlock's own `--proxy`/`-p` (or `--tor`) yourself, the launcher steps aside: it forwards your arguments unchanged and does not inject a ProxyHat URL.
84
+
85
+ ## Using the URL builder directly
86
+
87
+ Need the gateway URL in your own script or a different tool? Import the helper:
88
+
89
+ ```python
90
+ from sherlock_proxyhat import proxyhat_proxy_url
91
+
92
+ url = proxyhat_proxy_url(api_key="ph_xxx", country="us")
93
+ # -> "http://<user>-country-us:<pass>@gate.proxyhat.com:8080"
94
+ ```
95
+
96
+ Rotating by default (`sticky=None`); pass `sticky="30m"` (or `True`) to pin one IP.
97
+
98
+ ## How it works
99
+
100
+ The launcher parses out its `--proxyhat-*` options, resolves your gateway credentials via the official [`proxyhat`](https://pypi.org/project/proxyhat/) SDK (an API key auto-picks an active sub-user, or pass `username`/`password`), and builds a connection URL carrying ProxyHat's targeting grammar (`http://<user>-country-us:<pass>@gate.proxyhat.com:8080`, or `socks5://…:1080`). It then finds the `sherlock` executable on your `PATH` and runs `sherlock --proxy <url> <your other args>` as a subprocess, returning Sherlock's exit code. A rotating username makes the gateway hand out a fresh residential IP per connection; `--proxyhat-sticky` pins one.
101
+
102
+ ## Roadmap
103
+
104
+ This launcher wraps Sherlock's existing `--proxy` flag on purpose — it's the honest, low-friction way to ship today. A **planned follow-up** is an upstream PR to Sherlock adding a native `--proxyhat` flag (resolve credentials and geo-target from within Sherlock itself, no wrapper), so ProxyHat becomes a first-class option in the tool. Until that lands, this package is the supported path.
105
+
106
+ ## License
107
+
108
+ MIT © [ProxyHat](https://proxyhat.com)
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal sherlock-proxyhat example.
3
+ #
4
+ # PROXYHAT_API_KEY=ph_xxx ./examples/basic.sh johndoe
5
+ #
6
+ # Runs Sherlock through a US residential IP, rotating a fresh IP per connection
7
+ # (the default) so the hundreds of per-site checks are spread across many IPs.
8
+ set -euo pipefail
9
+
10
+ USERNAME="${1:-johndoe}"
11
+
12
+ # api_key defaults to $PROXYHAT_API_KEY and auto-selects an active sub-user.
13
+ # Everything after the --proxyhat-* flags is passed straight to `sherlock`.
14
+ sherlock-proxyhat \
15
+ --proxyhat-country us \
16
+ "$USERNAME" \
17
+ --timeout 30
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sherlock-proxyhat"
7
+ version = "0.1.0"
8
+ description = "Run the Sherlock OSINT username hunter through ProxyHat residential proxies — rotating IPs, geo-targeting, fewer blocks."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "ProxyHat", email = "support@proxyhat.com" }]
13
+ keywords = [
14
+ "sherlock",
15
+ "osint",
16
+ "proxy",
17
+ "residential-proxy",
18
+ "proxyhat",
19
+ "username",
20
+ "reconnaissance",
21
+ "rotating-proxy",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Environment :: Console",
26
+ "Intended Audience :: Developers",
27
+ "Intended Audience :: Information Technology",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Internet :: Proxy Servers",
34
+ "Topic :: Security",
35
+ ]
36
+ dependencies = ["proxyhat>=0.2.0"]
37
+
38
+ [project.scripts]
39
+ sherlock-proxyhat = "sherlock_proxyhat.cli:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://proxyhat.com"
43
+ Documentation = "https://docs.proxyhat.com"
44
+ Repository = "https://github.com/ProxyHatCom/sherlock-proxyhat"
45
+
46
+ [project.optional-dependencies]
47
+ # Sherlock is the app this launcher shells out to. It's optional because we call
48
+ # the `sherlock` executable via subprocess (never import it), so you can also use
49
+ # a sherlock installed separately (pipx, system package, etc.).
50
+ sherlock = ["sherlock-project>=0.14"]
51
+ dev = ["pytest>=7.0", "ruff>=0.4"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/sherlock_proxyhat"]
55
+
56
+ [tool.ruff]
57
+ target-version = "py311"
58
+ line-length = 120
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "N", "W", "UP"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": ["config:recommended", ":semanticCommits"],
4
+ "labels": ["dependencies"],
5
+ "schedule": ["before 6am on monday"],
6
+ "packageRules": [
7
+ {
8
+ "description": "Auto-merge dev-dependency and upstream bumps once CI (incl. compat) is green.",
9
+ "matchDepTypes": ["build-system.requires", "project.dependencies", "project.optional-dependencies"],
10
+ "automerge": true,
11
+ "automergeType": "pr"
12
+ },
13
+ {
14
+ "description": "Track the sherlock-project range but never auto-widen it silently.",
15
+ "matchPackageNames": ["sherlock-project"],
16
+ "automerge": false
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,13 @@
1
+ """sherlock-proxyhat — run the Sherlock OSINT tool through ProxyHat residential proxies."""
2
+
3
+ from sherlock_proxyhat._resolve import ProxyHatConfigError, resolve_credentials
4
+ from sherlock_proxyhat.cli import build_command, main, proxyhat_proxy_url
5
+
6
+ __all__ = [
7
+ "ProxyHatConfigError",
8
+ "build_command",
9
+ "main",
10
+ "proxyhat_proxy_url",
11
+ "resolve_credentials",
12
+ ]
13
+ __version__ = "0.1.0"
@@ -0,0 +1,71 @@
1
+ """Gateway credential resolution for sherlock-proxyhat.
2
+
3
+ Mirrors the other ProxyHat integrations: explicit ``username``/``password``
4
+ (or ``PROXYHAT_USERNAME``/``PROXYHAT_PASSWORD``) win; otherwise an API key
5
+ (``PROXYHAT_API_KEY``) looks up your sub-users via the official ``proxyhat`` SDK
6
+ and picks an active one with remaining traffic — or the one named by ``sub_user``.
7
+ Everything here except the sub-user lookup is offline.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ from proxyhat import ProxyHat
15
+
16
+
17
+ class ProxyHatConfigError(RuntimeError):
18
+ """Raised when ProxyHat credentials cannot be resolved."""
19
+
20
+
21
+ def _env(name: str) -> str | None:
22
+ value = os.environ.get(name)
23
+ return value.strip() if value and value.strip() else None
24
+
25
+
26
+ def resolve_credentials(
27
+ *,
28
+ api_key: str | None = None,
29
+ username: str | None = None,
30
+ password: str | None = None,
31
+ sub_user: str | None = None,
32
+ ) -> tuple[str, str]:
33
+ """Resolve a sub-user's ``(proxy_username, proxy_password)``.
34
+
35
+ Options win over environment variables. Precedence:
36
+
37
+ 1. explicit ``username`` + ``password`` (``PROXYHAT_USERNAME`` / ``PROXYHAT_PASSWORD``)
38
+ 2. ``api_key`` (``PROXYHAT_API_KEY``) → auto-pick an active sub-user, or the
39
+ one named by ``sub_user`` (``PROXYHAT_SUBUSER``).
40
+ """
41
+ username = username or _env("PROXYHAT_USERNAME")
42
+ password = password or _env("PROXYHAT_PASSWORD")
43
+ if username and password:
44
+ return username, password
45
+
46
+ api_key = api_key or _env("PROXYHAT_API_KEY")
47
+ if not api_key:
48
+ raise ProxyHatConfigError(
49
+ "sherlock-proxyhat: no credentials. Pass --proxyhat-api-key (or PROXYHAT_API_KEY), "
50
+ "or --proxyhat-username + --proxyhat-password (PROXYHAT_USERNAME / PROXYHAT_PASSWORD)."
51
+ )
52
+
53
+ return _resolve_sub_user(api_key, sub_user or _env("PROXYHAT_SUBUSER"))
54
+
55
+
56
+ def _resolve_sub_user(api_key: str, want: str | None) -> tuple[str, str]:
57
+ users = ProxyHat(api_key=api_key).sub_users.list()
58
+ usable = [u for u in users if not u.suspended_at and (u.traffic_limit == 0 or u.used_traffic < u.traffic_limit)]
59
+ if want:
60
+ chosen = next((u for u in users if u.uuid == want or u.name == want), None)
61
+ else:
62
+ chosen = usable[0] if usable else None
63
+
64
+ if chosen is None or not chosen.proxy_username or not chosen.proxy_password:
65
+ raise ProxyHatConfigError(
66
+ f'sherlock-proxyhat: no sub-user matched "{want}" (or it has no proxy credentials).'
67
+ if want
68
+ else "sherlock-proxyhat: no usable sub-user found (all suspended or out of traffic). "
69
+ "Create one, top up, or pass --proxyhat-sub-user."
70
+ )
71
+ return chosen.proxy_username, chosen.proxy_password
@@ -0,0 +1,203 @@
1
+ """``sherlock-proxyhat`` — a thin launcher that runs Sherlock through ProxyHat residential proxies.
2
+
3
+ Sherlock (https://github.com/sherlock-project/sherlock) is an OSINT *app*, not a
4
+ library: it hunts a username across hundreds of sites and accepts a single
5
+ ``--proxy <url>`` for all of them. Firing that many requests from one datacenter
6
+ IP gets rate-limited and blocked fast, so this launcher resolves a ProxyHat
7
+ residential gateway URL and invokes the real ``sherlock`` executable with
8
+ ``--proxy <url>`` prepended, passing every other argument (usernames + flags)
9
+ straight through.
10
+
11
+ Because Sherlock reuses the one proxy for the whole run, we build a **rotating**
12
+ gateway username by default — the ProxyHat gateway then hands out a fresh
13
+ residential IP per connection, spreading the hundreds of site checks across many
14
+ IPs. Pass ``--proxyhat-sticky`` to pin one IP instead.
15
+
16
+ This is deliberately a wrapper around Sherlock's existing ``--proxy`` flag, not a
17
+ fork. A native ``--proxyhat`` flag upstream is a planned follow-up (see README).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ from collections.abc import Sequence
27
+
28
+ from proxyhat import build_connection_url
29
+
30
+ from sherlock_proxyhat._resolve import ProxyHatConfigError, resolve_credentials
31
+
32
+ # Sherlock makes one proxy serve the entire multi-site run, so the sensible
33
+ # default is to rotate: a fresh residential IP per connection spreads the load
34
+ # and dodges per-IP rate limits. sticky is opt-in via --proxyhat-sticky.
35
+ DEFAULT_STICKY: bool | str | None = None
36
+
37
+
38
+ def proxyhat_proxy_url(
39
+ *,
40
+ api_key: str | None = None,
41
+ username: str | None = None,
42
+ password: str | None = None,
43
+ sub_user: str | None = None,
44
+ country: str | None = None,
45
+ region: str | None = None,
46
+ city: str | None = None,
47
+ sticky: bool | str | None = DEFAULT_STICKY,
48
+ filter: str | None = None,
49
+ protocol: str = "http",
50
+ ) -> str:
51
+ """Return a full ProxyHat gateway proxy URL for Sherlock's ``--proxy``.
52
+
53
+ Resolves credentials (``api_key``/``PROXYHAT_API_KEY`` auto-picks an active
54
+ sub-user, or pass ``username``/``password``) then builds a connection URL like
55
+ ``http://<user>-country-us:<pass>@gate.proxyhat.com:8080`` (or ``socks5://…:1080``)
56
+ via the official ``proxyhat`` SDK's targeting grammar.
57
+
58
+ Rotating by default (``sticky=None``): the gateway hands out a fresh
59
+ residential IP per connection. Pass ``sticky="30m"`` (or ``True``) to pin one
60
+ IP for the whole run. Geo/quality: ``country`` (ISO code or ``"any"``),
61
+ ``region``, ``city``, ``filter`` (AI IP-quality tier). ``protocol`` is
62
+ ``"http"`` or ``"socks5"``.
63
+ """
64
+ user, pw = resolve_credentials(
65
+ api_key=api_key,
66
+ username=username,
67
+ password=password,
68
+ sub_user=sub_user,
69
+ )
70
+ return build_connection_url(
71
+ username=user,
72
+ password=pw,
73
+ country=country,
74
+ region=region,
75
+ city=city,
76
+ sticky=sticky,
77
+ filter=filter,
78
+ protocol=protocol,
79
+ )
80
+
81
+
82
+ def _build_parser() -> argparse.ArgumentParser:
83
+ """Parser for the ``--proxyhat-*`` options this launcher owns.
84
+
85
+ Everything else (usernames and all of Sherlock's own flags) is left for
86
+ ``parse_known_args`` to return untouched, so we never have to track Sherlock's
87
+ argument surface.
88
+ """
89
+ parser = argparse.ArgumentParser(
90
+ prog="sherlock-proxyhat",
91
+ description=(
92
+ "Run Sherlock through ProxyHat residential proxies. All arguments other than "
93
+ "--proxyhat-* are passed straight through to the `sherlock` executable."
94
+ ),
95
+ add_help=False, # let --help fall through to sherlock; use --proxyhat-help for ours
96
+ # Never abbreviation-match: a bare `--proxy` (Sherlock's own flag) must fall
97
+ # through to passthrough, not be swallowed as a prefix of `--proxyhat-*`.
98
+ allow_abbrev=False,
99
+ )
100
+ g = parser.add_argument_group("proxyhat options (stripped before forwarding to sherlock)")
101
+ g.add_argument("--proxyhat-help", action="help", help="show this launcher's proxyhat options and exit")
102
+ g.add_argument("--proxyhat-api-key", metavar="KEY", help="ProxyHat API key (or env PROXYHAT_API_KEY)")
103
+ g.add_argument("--proxyhat-username", metavar="USER", help="explicit gateway proxy_username (or PROXYHAT_USERNAME)")
104
+ g.add_argument("--proxyhat-password", metavar="PASS", help="explicit gateway proxy_password (or PROXYHAT_PASSWORD)")
105
+ g.add_argument("--proxyhat-sub-user", metavar="ID", help="pick a sub-user by uuid or name (or PROXYHAT_SUBUSER)")
106
+ g.add_argument("--proxyhat-country", metavar="ISO", help='country ISO code, or "any" (default)')
107
+ g.add_argument("--proxyhat-region", metavar="NAME", help="region/state to target")
108
+ g.add_argument("--proxyhat-city", metavar="NAME", help="city to target")
109
+ g.add_argument("--proxyhat-filter", metavar="TIER", help="AI IP-quality tier (e.g. high)")
110
+ g.add_argument("--proxyhat-protocol", choices=("http", "socks5"), default="http", help="gateway protocol")
111
+ # Two flags rather than one optional-value flag: a bare `--proxyhat-sticky VALUE`
112
+ # would greedily swallow the following positional username as its value, so
113
+ # stickiness is a plain switch and the lifetime is a separate option.
114
+ g.add_argument(
115
+ "--proxyhat-sticky",
116
+ action="store_true",
117
+ help="pin one residential IP for the whole run (default: rotating — a fresh IP per connection)",
118
+ )
119
+ g.add_argument(
120
+ "--proxyhat-sticky-ttl",
121
+ metavar="TTL",
122
+ help='sticky session lifetime like "30m"/"2h" (implies --proxyhat-sticky; default 30m)',
123
+ )
124
+ return parser
125
+
126
+
127
+ def _resolve_sticky(*, sticky: bool, ttl: str | None) -> bool | str | None:
128
+ """Fold the two sticky flags into the SDK's ``sticky`` argument.
129
+
130
+ A TTL implies sticky and sets the lifetime; a bare ``--proxyhat-sticky`` pins
131
+ with the SDK default (30m); neither means rotating.
132
+ """
133
+ if ttl:
134
+ return ttl
135
+ if sticky:
136
+ return True
137
+ return DEFAULT_STICKY
138
+
139
+
140
+ def _has_user_proxy(passthrough: Sequence[str]) -> bool:
141
+ """True if the user already supplied Sherlock's own --proxy/-p flag."""
142
+ return any(a == "--proxy" or a == "-p" or a.startswith("--proxy=") for a in passthrough)
143
+
144
+
145
+ def build_command(argv: Sequence[str]) -> list[str]:
146
+ """Resolve the ProxyHat proxy and build the full ``sherlock`` command line.
147
+
148
+ Returns ``[sherlock_exe, "--proxy", <url>, *passthrough]``. If the caller
149
+ already passed Sherlock's own ``--proxy``/``-p``, that wins and no ProxyHat URL
150
+ is injected (a note goes to stderr). Raises ``ProxyHatConfigError`` if
151
+ credentials can't be resolved and ``FileNotFoundError`` if ``sherlock`` isn't
152
+ installed.
153
+ """
154
+ ns, passthrough = _build_parser().parse_known_args(list(argv))
155
+
156
+ sherlock_exe = shutil.which("sherlock")
157
+ if sherlock_exe is None:
158
+ raise FileNotFoundError(
159
+ "sherlock-proxyhat: the `sherlock` executable was not found on PATH. "
160
+ "Install it with `pip install sherlock-proxyhat[sherlock]` or `pip install sherlock-project`."
161
+ )
162
+
163
+ if _has_user_proxy(passthrough):
164
+ print(
165
+ "sherlock-proxyhat: a --proxy/-p flag was supplied explicitly; using it and skipping the ProxyHat proxy.",
166
+ file=sys.stderr,
167
+ )
168
+ return [sherlock_exe, *passthrough]
169
+
170
+ url = proxyhat_proxy_url(
171
+ api_key=ns.proxyhat_api_key,
172
+ username=ns.proxyhat_username,
173
+ password=ns.proxyhat_password,
174
+ sub_user=ns.proxyhat_sub_user,
175
+ country=ns.proxyhat_country,
176
+ region=ns.proxyhat_region,
177
+ city=ns.proxyhat_city,
178
+ sticky=_resolve_sticky(sticky=ns.proxyhat_sticky, ttl=ns.proxyhat_sticky_ttl),
179
+ filter=ns.proxyhat_filter,
180
+ protocol=ns.proxyhat_protocol,
181
+ )
182
+ return [sherlock_exe, "--proxy", url, *passthrough]
183
+
184
+
185
+ def main(argv: Sequence[str] | None = None) -> int:
186
+ """Entry point for the ``sherlock-proxyhat`` console script.
187
+
188
+ Builds the command and hands off to the real ``sherlock`` via subprocess,
189
+ returning its exit code.
190
+ """
191
+ argv = list(sys.argv[1:] if argv is None else argv)
192
+ try:
193
+ command = build_command(argv)
194
+ except (ProxyHatConfigError, FileNotFoundError) as exc:
195
+ print(str(exc), file=sys.stderr)
196
+ return 2
197
+
198
+ completed = subprocess.run(command)
199
+ return completed.returncode
200
+
201
+
202
+ if __name__ == "__main__": # pragma: no cover
203
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ """Tests for the launcher wiring.
2
+
3
+ Sherlock is a separate executable, so instead of installing it we fake
4
+ ``shutil.which`` (to pretend ``sherlock`` is on PATH) and ``subprocess.run`` (to
5
+ capture the exact command line without running anything). Credentials are passed
6
+ explicitly so no network or SDK call is made. These assert the built ``--proxy``
7
+ URL reflects geo/rotation, that ``--proxyhat-*`` flags are stripped, and that all
8
+ other args reach ``sherlock`` untouched.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from sherlock_proxyhat import build_command, cli, main
16
+
17
+
18
+ @pytest.fixture
19
+ def fake_sherlock(monkeypatch):
20
+ """Pretend `sherlock` is installed at a fixed path and capture subprocess.run."""
21
+ captured = {}
22
+
23
+ def fake_run(command, *args, **kwargs):
24
+ captured["command"] = command
25
+
26
+ class Completed:
27
+ returncode = 0
28
+
29
+ return Completed()
30
+
31
+ monkeypatch.setattr(cli.shutil, "which", lambda name: "/usr/bin/sherlock" if name == "sherlock" else None)
32
+ monkeypatch.setattr(cli.subprocess, "run", fake_run)
33
+ return captured
34
+
35
+
36
+ CREDS = ["--proxyhat-username", "ph-1", "--proxyhat-password", "pw"]
37
+
38
+
39
+ @pytest.fixture
40
+ def fake_which(monkeypatch):
41
+ """Pretend `sherlock` is installed at a fixed path (build_command needs it on PATH)."""
42
+ monkeypatch.setattr(cli.shutil, "which", lambda name: "/usr/bin/sherlock" if name == "sherlock" else None)
43
+
44
+
45
+ @pytest.mark.usefixtures("fake_which")
46
+ class TestBuildCommand:
47
+ def test_injects_proxy_and_passes_through_args(self):
48
+ cmd = build_command([*CREDS, "johndoe", "janedoe", "--timeout", "30"])
49
+ assert cmd[0].endswith("sherlock")
50
+ assert cmd[1] == "--proxy"
51
+ url = cmd[2]
52
+ assert url.startswith("http://ph-1-country-any:pw@gate.proxyhat.com:8080")
53
+ # Usernames and sherlock's own flags pass through in order, proxyhat flags stripped.
54
+ assert cmd[3:] == ["johndoe", "janedoe", "--timeout", "30"]
55
+ assert not any(a.startswith("--proxyhat-") for a in cmd)
56
+
57
+ def test_rotating_by_default(self):
58
+ cmd = build_command([*CREDS, "johndoe"])
59
+ assert "-sid-" not in cmd[2] and "-ttl-" not in cmd[2]
60
+
61
+ def test_geo_and_sticky_ttl_reflected_in_url(self):
62
+ cmd = build_command([*CREDS, "--proxyhat-country", "de", "--proxyhat-sticky-ttl", "2h", "johndoe"])
63
+ url = cmd[2]
64
+ assert "ph-1-country-de" in url
65
+ assert "-ttl-2h" in url
66
+ assert cmd[3:] == ["johndoe"]
67
+
68
+ def test_sticky_bare_flag_pins_ip_without_eating_username(self):
69
+ # The sticky switch must not swallow the following positional username.
70
+ cmd = build_command([*CREDS, "--proxyhat-sticky", "johndoe"])
71
+ assert "-sid-" in cmd[2] and "-ttl-30m" in cmd[2]
72
+ assert cmd[3:] == ["johndoe"]
73
+
74
+ def test_socks5_protocol(self):
75
+ cmd = build_command([*CREDS, "--proxyhat-protocol", "socks5", "johndoe"])
76
+ assert cmd[2].startswith("socks5://ph-1-country-any:pw@gate.proxyhat.com:1080")
77
+
78
+ def test_user_supplied_proxy_wins(self, capsys):
79
+ cmd = build_command([*CREDS, "johndoe", "--proxy", "socks5://127.0.0.1:9050"])
80
+ # No ProxyHat proxy injected; the user's own args are forwarded verbatim.
81
+ assert cmd[1:] == ["johndoe", "--proxy", "socks5://127.0.0.1:9050"]
82
+ assert "skipping the ProxyHat proxy" in capsys.readouterr().err
83
+
84
+ def test_missing_sherlock_raises(self, monkeypatch):
85
+ monkeypatch.setattr(cli.shutil, "which", lambda name: None)
86
+ with pytest.raises(FileNotFoundError):
87
+ build_command([*CREDS, "johndoe"])
88
+
89
+
90
+ class TestMain:
91
+ def test_main_runs_sherlock_and_returns_code(self, fake_sherlock):
92
+ rc = main([*CREDS, "--proxyhat-country", "us", "johndoe"])
93
+ assert rc == 0
94
+ command = fake_sherlock["command"]
95
+ assert command[0] == "/usr/bin/sherlock"
96
+ assert command[1] == "--proxy"
97
+ assert command[2].startswith("http://ph-1-country-us:pw@")
98
+ assert command[3] == "johndoe"
99
+
100
+ def test_main_missing_sherlock_returns_2(self, monkeypatch):
101
+ monkeypatch.setattr(cli.shutil, "which", lambda name: None)
102
+ assert main([*CREDS, "johndoe"]) == 2
103
+
104
+ def test_main_bad_credentials_returns_2(self, fake_sherlock, monkeypatch):
105
+ for var in ("PROXYHAT_API_KEY", "PROXYHAT_USERNAME", "PROXYHAT_PASSWORD", "PROXYHAT_SUBUSER"):
106
+ monkeypatch.delenv(var, raising=False)
107
+ assert main(["johndoe"]) == 2
@@ -0,0 +1,101 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+
5
+ from sherlock_proxyhat import ProxyHatConfigError, proxyhat_proxy_url, resolve_credentials
6
+
7
+
8
+ def sub_user(**kw):
9
+ base = dict(
10
+ uuid="u",
11
+ name=None,
12
+ proxy_username="ph-1",
13
+ proxy_password="pw",
14
+ traffic_limit=0,
15
+ used_traffic=0,
16
+ suspended_at=None,
17
+ )
18
+ base.update(kw)
19
+ return SimpleNamespace(**base)
20
+
21
+
22
+ class TestProxyUrl:
23
+ def test_url_shape_and_geo(self):
24
+ url = proxyhat_proxy_url(username="ph-1", password="pw", country="us")
25
+ assert url.startswith("http://ph-1-country-us:pw@gate.proxyhat.com:8080")
26
+
27
+ def test_rotating_by_default(self):
28
+ # Sherlock reuses one proxy for the whole run, so the default rotates:
29
+ # no session id / ttl means the gateway hands out a fresh IP per connection.
30
+ url = proxyhat_proxy_url(username="ph-1", password="pw")
31
+ assert "-sid-" not in url
32
+ assert "-ttl-" not in url
33
+ assert url.startswith("http://ph-1-country-any:pw@")
34
+
35
+ def test_sticky_opt_in_pins_ip(self):
36
+ url = proxyhat_proxy_url(username="ph-1", password="pw", sticky=True)
37
+ assert "-sid-" in url
38
+ assert "-ttl-30m" in url
39
+
40
+ def test_custom_sticky_ttl(self):
41
+ url = proxyhat_proxy_url(username="ph-1", password="pw", sticky="2h")
42
+ assert "-ttl-2h" in url
43
+
44
+ def test_full_geo_targeting(self):
45
+ url = proxyhat_proxy_url(
46
+ username="ph-1",
47
+ password="pw",
48
+ country="de",
49
+ region="berlin",
50
+ city="berlin",
51
+ filter="high",
52
+ )
53
+ assert "ph-1-country-de" in url
54
+ assert "-region-berlin" in url
55
+ assert "-city-berlin" in url
56
+ assert "-filter-high" in url
57
+
58
+ def test_socks5_protocol(self):
59
+ url = proxyhat_proxy_url(username="ph-1", password="pw", protocol="socks5")
60
+ assert url.startswith("socks5://ph-1-country-any:pw@gate.proxyhat.com:1080")
61
+
62
+
63
+ class TestCredentialResolution:
64
+ def test_explicit_username_password(self):
65
+ assert resolve_credentials(username="ph-1", password="pw") == ("ph-1", "pw")
66
+
67
+ def test_raises_without_credentials(self, monkeypatch):
68
+ for var in ("PROXYHAT_API_KEY", "PROXYHAT_USERNAME", "PROXYHAT_PASSWORD", "PROXYHAT_SUBUSER"):
69
+ monkeypatch.delenv(var, raising=False)
70
+ with pytest.raises(ProxyHatConfigError):
71
+ resolve_credentials()
72
+
73
+ def test_api_key_picks_active_sub_user(self, monkeypatch):
74
+ users = [
75
+ sub_user(uuid="s", proxy_username="susp", suspended_at="2026-01-01"),
76
+ sub_user(uuid="g", proxy_username="good", traffic_limit=100, used_traffic=100),
77
+ sub_user(uuid="ok", proxy_username="ok", traffic_limit=100, used_traffic=1),
78
+ ]
79
+ fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
80
+ monkeypatch.setattr("sherlock_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
81
+ assert resolve_credentials(api_key="ph_key") == ("ok", "pw")
82
+
83
+ def test_api_key_named_sub_user(self, monkeypatch):
84
+ users = [sub_user(uuid="a", proxy_username="a"), sub_user(uuid="b", name="prod", proxy_username="b")]
85
+ fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
86
+ monkeypatch.setattr("sherlock_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
87
+ assert resolve_credentials(api_key="ph_key", sub_user="prod") == ("b", "pw")
88
+
89
+ def test_api_key_no_usable_sub_user(self, monkeypatch):
90
+ users = [sub_user(traffic_limit=100, used_traffic=100)]
91
+ fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
92
+ monkeypatch.setattr("sherlock_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
93
+ with pytest.raises(ProxyHatConfigError):
94
+ resolve_credentials(api_key="ph_key")
95
+
96
+ def test_url_resolves_via_api_key(self, monkeypatch):
97
+ users = [sub_user(proxy_username="good", proxy_password="secret")]
98
+ fake_client = SimpleNamespace(sub_users=SimpleNamespace(list=lambda: users))
99
+ monkeypatch.setattr("sherlock_proxyhat._resolve.ProxyHat", lambda **kw: fake_client)
100
+ url = proxyhat_proxy_url(api_key="ph_key", country="us")
101
+ assert url.startswith("http://good-country-us:secret@gate.proxyhat.com:8080")