warmpath 0.2.0__tar.gz → 0.3.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.
- {warmpath-0.2.0 → warmpath-0.3.0}/PKG-INFO +4 -2
- warmpath-0.3.0/README.md +80 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/Taskfile.yaml +1 -1
- {warmpath-0.2.0 → warmpath-0.3.0}/docs/skill.md +1 -1
- {warmpath-0.2.0 → warmpath-0.3.0}/pyproject.toml +6 -2
- {warmpath-0.2.0 → warmpath-0.3.0}/scripts/release.sh +6 -6
- warmpath-0.3.0/tests/test_auth.py +269 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/tests/test_cli_commands.py +14 -15
- {warmpath-0.2.0 → warmpath-0.3.0}/tests/test_mutual_connections.py +23 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/uv.lock +181 -2
- warmpath-0.3.0/warmpath/auth.py +232 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/warmpath/cli.py +106 -57
- warmpath-0.2.0/README.md +0 -77
- {warmpath-0.2.0 → warmpath-0.3.0}/.gitignore +0 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/AGENTS.md +0 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/UNLICENSE +0 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/warmpath/__init__.py +0 -0
- {warmpath-0.2.0 → warmpath-0.3.0}/warmpath.jpg +0 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: warmpath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local LinkedIn visible-connections scraper.
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: browser-cookie3>=0.20.1
|
|
6
7
|
Requires-Dist: open-linkedin-api>=2.3.1
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.8
|
warmpath-0.3.0/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Warmpath
|
|
4
|
+
|
|
5
|
+
Find LinkedIn mutuals and warm paths using your logged-in browser session.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Log in to LinkedIn in a regular browser window, then import that session:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uvx warmpath auth import --browser chrome
|
|
13
|
+
uvx warmpath auth status
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Import uses [browser-cookie3](https://github.com/borisbabic/browser_cookie3) to read your browser's local cookie store. Supported browsers are Chrome, Chromium, Firefox, Edge, Brave, Safari, Arc, Vivaldi, Opera, Opera GX (`opera-gx`), and LibreWolf; availability depends on your operating system. Your OS may ask for keychain/keyring access. If the cookie store is locked, close the browser and retry. Private-window sessions cannot be imported.
|
|
17
|
+
|
|
18
|
+
Warmpath imports only LinkedIn's `li_at` and `JSESSIONID` cookies and manages the saved session automatically. `company`, `skill`, and `human` use it on subsequent runs. Failed imports preserve the previous session.
|
|
19
|
+
|
|
20
|
+
`auth status` shows the source browser, import time, cookie expiry, and storage location without displaying cookie values. It checks the saved session locally; LinkedIn may revoke a session before its cookies expire. If the session expires or LinkedIn stops accepting it, log in again and repeat `auth import`.
|
|
21
|
+
|
|
22
|
+
## Development
|
|
23
|
+
|
|
24
|
+
[go-task](https://taskfile.dev/) is required to run the repository checks. Install it with Homebrew on macOS:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
brew install go-task
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For other platforms, follow the [go-task installation guide](https://taskfile.dev/docs/installation). Verify that the `task` command is available before running any checks:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
task --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then install the Python development dependencies and run the full check suite:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv sync
|
|
40
|
+
task check
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Company
|
|
46
|
+
|
|
47
|
+
Who can introduce me into this company?
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
uvx warmpath company HashiCorp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Skill
|
|
54
|
+
|
|
55
|
+
Which reachable people match this recruiting need?
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uvx warmpath skill Flutter
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Human
|
|
62
|
+
|
|
63
|
+
Can I reach this exact person, and through whom?
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## More Examples
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
uvx warmpath company "HashiCorp" --max-degree 2 --limit 5
|
|
73
|
+
uvx warmpath auth import --browser firefox
|
|
74
|
+
uvx warmpath company https://www.linkedin.com/company/hashicorp/
|
|
75
|
+
uvx warmpath skill Leadership --max-depth 2
|
|
76
|
+
uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
|
|
77
|
+
uvx warmpath company --help
|
|
78
|
+
uvx warmpath skill --help
|
|
79
|
+
uvx warmpath human --help
|
|
80
|
+
```
|
|
@@ -18,7 +18,7 @@ For the `skill` subcommand, the call path is:
|
|
|
18
18
|
4. `find_skill_connections(...)`
|
|
19
19
|
5. `render_skill_connections_result(result)`
|
|
20
20
|
|
|
21
|
-
`run_skill_command` builds a LinkedIn API client from the
|
|
21
|
+
`run_skill_command` builds a LinkedIn API client from the session saved by `warmpath auth import --browser <browser>`, resolves the cache directory, calls the skill search pipeline, and prints the rendered result. Use `warmpath auth status` to check the saved session's expiry locally.
|
|
22
22
|
|
|
23
23
|
## Defaults
|
|
24
24
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "warmpath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Local LinkedIn visible-connections scraper."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
|
-
dependencies = [
|
|
6
|
+
dependencies = [
|
|
7
|
+
"browser-cookie3>=0.20.1",
|
|
8
|
+
"open-linkedin-api>=2.3.1",
|
|
9
|
+
"platformdirs>=4.11.8",
|
|
10
|
+
]
|
|
7
11
|
|
|
8
12
|
[dependency-groups]
|
|
9
13
|
dev = ["pytest>=8.4.1", "pytest-xdist>=3.8.0", "pyright>=1.1.411"]
|
|
@@ -75,12 +75,12 @@ if uv run python -m zipfile -l "$wheel" | grep -q '\.uv-cache'; then
|
|
|
75
75
|
exit 1
|
|
76
76
|
fi
|
|
77
77
|
|
|
78
|
-
echo "Tagging ${PACKAGE} ${version}"
|
|
79
|
-
git tag -a "$tag" -m "${PACKAGE} ${version}"
|
|
80
|
-
git push origin "$tag"
|
|
81
|
-
|
|
82
78
|
echo "Publishing ${PACKAGE} ${version}"
|
|
83
79
|
UV_PUBLISH_TOKEN="$token" uv publish
|
|
84
80
|
|
|
85
|
-
echo "Smoke-testing uvx ${PACKAGE}"
|
|
86
|
-
uvx --refresh-package "$PACKAGE" "$PACKAGE" --help
|
|
81
|
+
echo "Smoke-testing uvx ${PACKAGE} ${version}"
|
|
82
|
+
uvx --refresh-package "$PACKAGE" --from "${PACKAGE}==${version}" "$PACKAGE" --help
|
|
83
|
+
|
|
84
|
+
echo "Tagging ${PACKAGE} ${version}"
|
|
85
|
+
git tag -a "$tag" -m "${PACKAGE} ${version}"
|
|
86
|
+
git push origin "$tag"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from requests import Request
|
|
8
|
+
from requests.cookies import RequestsCookieJar
|
|
9
|
+
|
|
10
|
+
from warmpath import auth, cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def auth_path(tmp_path, monkeypatch):
|
|
15
|
+
path = tmp_path / "warmpath" / "auth.json"
|
|
16
|
+
monkeypatch.setattr(auth, "auth_store_path", lambda: path)
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def browser_cookies():
|
|
22
|
+
jar = RequestsCookieJar()
|
|
23
|
+
jar.set(
|
|
24
|
+
"li_at", "private-linkedin-token", domain=".linkedin.com", path="/",
|
|
25
|
+
secure=True, expires=4102444800,
|
|
26
|
+
)
|
|
27
|
+
jar.set(
|
|
28
|
+
"JSESSIONID", '"ajax:private-csrf-token"', domain="www.linkedin.com",
|
|
29
|
+
path="/", secure=True,
|
|
30
|
+
)
|
|
31
|
+
next(cookie for cookie in jar if cookie.name == "JSESSIONID").domain_specified = False
|
|
32
|
+
return jar
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def reader(browser_cookies, monkeypatch):
|
|
37
|
+
reader = Mock(return_value=browser_cookies)
|
|
38
|
+
monkeypatch.setattr(auth.browser_cookie3, "chrome", reader)
|
|
39
|
+
return reader
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("browser", auth.BROWSERS)
|
|
43
|
+
def test_import_reads_only_the_selected_browser(browser, browser_cookies, monkeypatch):
|
|
44
|
+
reader = Mock(return_value=browser_cookies)
|
|
45
|
+
monkeypatch.setattr(auth.browser_cookie3, browser.replace("-", "_"), reader)
|
|
46
|
+
|
|
47
|
+
cli.main(["auth", "import", "--browser", browser])
|
|
48
|
+
|
|
49
|
+
reader.assert_called_once_with(domain_name="linkedin.com")
|
|
50
|
+
assert auth.load_auth().browser == browser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_import_filters_unrelated_expired_and_out_of_scope_cookies(reader, browser_cookies, auth_path):
|
|
54
|
+
for name, domain, path, expires, value in [
|
|
55
|
+
("tracking", ".linkedin.com", "/", None, "unrelated-tracking-token"),
|
|
56
|
+
("li_at", ".notlinkedin.com", "/", None, "unrelated-domain-token"),
|
|
57
|
+
("li_at", ".linkedin.com.example.org", "/", None, "unrelated-suffix-token"),
|
|
58
|
+
("li_at", "jobs.linkedin.com", "/", None, "unrelated-host-token"),
|
|
59
|
+
("li_at", ".linkedin.com", "/jobs", None, "unrelated-path-token"),
|
|
60
|
+
("li_at", "www.linkedin.com", "/", 1, "expired-token"),
|
|
61
|
+
("JSESSIONID", ".linkedin.com", "/", None, "invalid;token"),
|
|
62
|
+
]:
|
|
63
|
+
browser_cookies.set(name, value, domain=domain, path=path, expires=expires)
|
|
64
|
+
|
|
65
|
+
auth.import_browser("chrome")
|
|
66
|
+
|
|
67
|
+
saved = auth.load_cookies()
|
|
68
|
+
assert saved.get_dict() == {
|
|
69
|
+
"li_at": "private-linkedin-token",
|
|
70
|
+
"JSESSIONID": '"ajax:private-csrf-token"',
|
|
71
|
+
}
|
|
72
|
+
assert len(saved) == 2
|
|
73
|
+
assert "unrelated" not in auth_path.read_text()
|
|
74
|
+
assert "expired-token" not in auth_path.read_text()
|
|
75
|
+
assert "invalid;token" not in auth_path.read_text()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_imported_session_reaches_linkedin_with_csrf_and_cookie_attributes(reader, browser_cookies):
|
|
79
|
+
# A broader JSESSIONID must not conflict with the www cookie in the API's
|
|
80
|
+
# lookup by name, or replace the token associated with that scope.
|
|
81
|
+
browser_cookies.set("JSESSIONID", '"ajax:broader-token"', domain=".linkedin.com")
|
|
82
|
+
auth.import_browser("chrome")
|
|
83
|
+
|
|
84
|
+
api = cli.build_api()
|
|
85
|
+
session = api.client.session
|
|
86
|
+
request = session.prepare_request(Request("GET", "https://www.linkedin.com/voyager/api/me"))
|
|
87
|
+
|
|
88
|
+
assert request.headers["csrf-token"] == "ajax:private-csrf-token"
|
|
89
|
+
assert "li_at=private-linkedin-token" in request.headers["Cookie"]
|
|
90
|
+
assert 'JSESSIONID="ajax:private-csrf-token"' in request.headers["Cookie"]
|
|
91
|
+
assert "broader-token" not in request.headers["Cookie"]
|
|
92
|
+
cookies = {cookie.name: cookie for cookie in session.cookies}
|
|
93
|
+
assert cookies["li_at"].expires == 4102444800
|
|
94
|
+
assert cookies["li_at"].domain == ".linkedin.com"
|
|
95
|
+
assert cookies["JSESSIONID"].expires is None
|
|
96
|
+
assert cookies["JSESSIONID"].domain_specified is False
|
|
97
|
+
assert all(cookie.secure for cookie in session.cookies)
|
|
98
|
+
assert "Cookie" not in session.prepare_request(Request("GET", "https://example.org/")).headers
|
|
99
|
+
assert "Cookie" not in session.prepare_request(Request("GET", "http://www.linkedin.com/")).headers
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_import_and_status_report_metadata_without_cookie_values(reader, capsys, auth_path):
|
|
103
|
+
cli.main(["auth", "import", "--browser", "Chrome"])
|
|
104
|
+
before = auth_path.read_bytes()
|
|
105
|
+
reader.reset_mock()
|
|
106
|
+
cli.main(["auth", "status"])
|
|
107
|
+
|
|
108
|
+
output = capsys.readouterr()
|
|
109
|
+
assert output.err == ""
|
|
110
|
+
assert output.out.count("Status: ready (local expiry check)") == 2
|
|
111
|
+
assert "Browser: chrome" in output.out
|
|
112
|
+
assert "Imported:" in output.out
|
|
113
|
+
assert str(auth_path) in output.out
|
|
114
|
+
assert "Cookie: JSESSIONID; expires: session" in output.out
|
|
115
|
+
assert "Cookie: li_at; expires: 2100-01-01T00:00:00+00:00" in output.out
|
|
116
|
+
assert "private-linkedin-token" not in output.out
|
|
117
|
+
assert "private-csrf-token" not in output.out
|
|
118
|
+
assert auth_path.read_bytes() == before
|
|
119
|
+
reader.assert_not_called()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.skipif(os.name != "posix", reason="Unix file permissions")
|
|
123
|
+
def test_auth_store_is_private_even_when_replacing_an_existing_file(reader, auth_path):
|
|
124
|
+
auth_path.parent.mkdir()
|
|
125
|
+
auth_path.write_text("previous session")
|
|
126
|
+
auth_path.chmod(0o644)
|
|
127
|
+
|
|
128
|
+
auth.import_browser("chrome")
|
|
129
|
+
|
|
130
|
+
assert auth_path.stat().st_mode & 0o777 == 0o600
|
|
131
|
+
assert list(auth_path.parent.iterdir()) == [auth_path]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.parametrize("condition", ["empty", "missing", "expired", "empty_value"])
|
|
135
|
+
def test_unusable_import_preserves_previous_session(condition, reader, browser_cookies, auth_path):
|
|
136
|
+
auth.import_browser("chrome")
|
|
137
|
+
before = auth_path.read_bytes()
|
|
138
|
+
if condition == "empty":
|
|
139
|
+
browser_cookies.clear()
|
|
140
|
+
elif condition == "missing":
|
|
141
|
+
browser_cookies.clear(".linkedin.com", "/", "li_at")
|
|
142
|
+
else:
|
|
143
|
+
cookie = next(cookie for cookie in browser_cookies if cookie.name == "li_at")
|
|
144
|
+
if condition == "expired":
|
|
145
|
+
cookie.expires = 1
|
|
146
|
+
else:
|
|
147
|
+
cookie.value = ""
|
|
148
|
+
|
|
149
|
+
with pytest.raises(auth.AuthError, match="No usable LinkedIn session"):
|
|
150
|
+
auth.import_browser("chrome")
|
|
151
|
+
|
|
152
|
+
assert auth_path.read_bytes() == before
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_browser_error_preserves_session_and_does_not_echo_secrets(reader, auth_path, capsys):
|
|
156
|
+
auth.import_browser("chrome")
|
|
157
|
+
before = auth_path.read_bytes()
|
|
158
|
+
reader.side_effect = RuntimeError("private-linkedin-token")
|
|
159
|
+
|
|
160
|
+
with pytest.raises(SystemExit) as error:
|
|
161
|
+
cli.main(["auth", "import", "--browser", "chrome"])
|
|
162
|
+
|
|
163
|
+
assert error.value.code == 2
|
|
164
|
+
output = capsys.readouterr()
|
|
165
|
+
assert "Could not read chrome cookies" in output.err
|
|
166
|
+
assert "private-linkedin-token" not in output.out + output.err
|
|
167
|
+
assert auth_path.read_bytes() == before
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_failed_save_preserves_previous_session_and_cleans_up(reader, auth_path, monkeypatch):
|
|
171
|
+
auth.import_browser("chrome")
|
|
172
|
+
before = auth_path.read_bytes()
|
|
173
|
+
monkeypatch.setattr(Path, "replace", Mock(side_effect=PermissionError("cannot replace")))
|
|
174
|
+
|
|
175
|
+
with pytest.raises(auth.AuthError, match="Could not save"):
|
|
176
|
+
auth.import_browser("chrome")
|
|
177
|
+
|
|
178
|
+
assert auth_path.read_bytes() == before
|
|
179
|
+
assert list(auth_path.parent.iterdir()) == [auth_path]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.parametrize("command", [
|
|
183
|
+
["auth", "status"], ["company", "Acme"], ["skill", "Python"],
|
|
184
|
+
["human", "https://www.linkedin.com/in/example/"],
|
|
185
|
+
])
|
|
186
|
+
def test_missing_auth_has_import_guidance_without_browser_or_api_access(command, reader, monkeypatch, capsys, auth_path):
|
|
187
|
+
linkedin = Mock()
|
|
188
|
+
monkeypatch.setattr(cli, "Linkedin", linkedin)
|
|
189
|
+
|
|
190
|
+
with pytest.raises(SystemExit) as error:
|
|
191
|
+
cli.main(command)
|
|
192
|
+
|
|
193
|
+
assert error.value.code == 2
|
|
194
|
+
assert "warmpath auth import --browser" in capsys.readouterr().err
|
|
195
|
+
assert not auth_path.exists()
|
|
196
|
+
reader.assert_not_called()
|
|
197
|
+
linkedin.assert_not_called()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.parametrize("content", [b"", b"[]", b"{}", b"private-token", b"\xff"])
|
|
201
|
+
def test_corrupt_store_has_reimport_guidance(content, auth_path, capsys):
|
|
202
|
+
auth_path.parent.mkdir()
|
|
203
|
+
auth_path.write_bytes(content)
|
|
204
|
+
|
|
205
|
+
with pytest.raises(SystemExit) as error:
|
|
206
|
+
cli.main(["auth", "status"])
|
|
207
|
+
|
|
208
|
+
assert error.value.code == 2
|
|
209
|
+
output = capsys.readouterr()
|
|
210
|
+
assert "saved LinkedIn session is invalid" in output.err
|
|
211
|
+
assert "warmpath auth import --browser" in output.err
|
|
212
|
+
assert "private-token" not in output.out + output.err
|
|
213
|
+
assert auth_path.read_bytes() == content
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.parametrize("field,value", [
|
|
217
|
+
("domain", "example.org"), ("path", "/jobs"), ("secure", "true"),
|
|
218
|
+
("expires", "tomorrow"), ("expires", 10**20),
|
|
219
|
+
("domain_specified", False), ("value", "token\r\nInjected: header"),
|
|
220
|
+
])
|
|
221
|
+
def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
|
|
222
|
+
auth.import_browser("chrome")
|
|
223
|
+
payload = json.loads(auth_path.read_text())
|
|
224
|
+
payload["cookies"][0][field] = value
|
|
225
|
+
auth_path.write_text(json.dumps(payload))
|
|
226
|
+
|
|
227
|
+
with pytest.raises(auth.AuthError, match="saved LinkedIn session is invalid"):
|
|
228
|
+
auth.load_cookies()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsys):
|
|
232
|
+
session = auth.import_browser("chrome")
|
|
233
|
+
next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
|
|
234
|
+
auth.save_auth(session)
|
|
235
|
+
reader.reset_mock()
|
|
236
|
+
|
|
237
|
+
with pytest.raises(SystemExit) as status_error:
|
|
238
|
+
cli.main(["auth", "status"])
|
|
239
|
+
|
|
240
|
+
assert status_error.value.code == 1
|
|
241
|
+
output = capsys.readouterr()
|
|
242
|
+
assert "Status: expired or incomplete" in output.out
|
|
243
|
+
assert "Missing or expired: li_at" in output.out
|
|
244
|
+
assert "warmpath auth import --browser chrome" in output.out
|
|
245
|
+
|
|
246
|
+
with pytest.raises(SystemExit) as api_error:
|
|
247
|
+
cli.build_api()
|
|
248
|
+
|
|
249
|
+
assert api_error.value.code == 2
|
|
250
|
+
assert "warmpath auth import --browser chrome" in capsys.readouterr().err
|
|
251
|
+
reader.assert_not_called()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
|
|
255
|
+
def test_auth_rejects_missing_and_unknown_arguments(arguments, reader):
|
|
256
|
+
with pytest.raises(SystemExit) as error:
|
|
257
|
+
cli.main(["auth", *arguments])
|
|
258
|
+
|
|
259
|
+
assert error.value.code == 2
|
|
260
|
+
reader.assert_not_called()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@pytest.mark.parametrize("command,target", [("company", "Acme"), ("skill", "Python"), ("human", "example")])
|
|
264
|
+
def test_manual_cookie_option_is_removed(command, target, capsys):
|
|
265
|
+
with pytest.raises(SystemExit) as error:
|
|
266
|
+
cli.main([command, target, "--cookie-file", "unused"])
|
|
267
|
+
|
|
268
|
+
assert error.value.code == 2
|
|
269
|
+
assert "unrecognized arguments" in capsys.readouterr().err
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
-
from warmpath import cli
|
|
8
|
+
from warmpath import auth, cli
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -30,24 +30,19 @@ def run_cli(*args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def linkedin_cookie_file() -> Path:
|
|
34
|
-
cookie_file = cli.DEFAULT_COOKIE_FILE
|
|
35
|
-
if not cookie_file.exists() or cookie_file.stat().st_size == 0:
|
|
36
|
-
pytest.skip("LinkedIn cookies are required for live integration tests")
|
|
37
|
-
return cookie_file
|
|
38
|
-
|
|
39
|
-
|
|
40
33
|
def run_live_cli(
|
|
41
34
|
tmp_path: Path,
|
|
42
35
|
*args: str,
|
|
43
36
|
timeout: int = 180,
|
|
44
37
|
) -> subprocess.CompletedProcess[str]:
|
|
38
|
+
if not auth.auth_store_path().exists():
|
|
39
|
+
pytest.skip("Run warmpath auth import --browser <browser> for live integration tests")
|
|
40
|
+
if auth.load_auth().missing_cookies():
|
|
41
|
+
pytest.skip("The imported LinkedIn session has expired; run warmpath auth import again")
|
|
45
42
|
return run_cli(
|
|
46
43
|
*args,
|
|
47
44
|
"--cache-dir",
|
|
48
45
|
str(tmp_path),
|
|
49
|
-
"--cookie-file",
|
|
50
|
-
str(linkedin_cookie_file()),
|
|
51
46
|
"--refresh-cache",
|
|
52
47
|
timeout=timeout,
|
|
53
48
|
)
|
|
@@ -79,6 +74,8 @@ def test_top_level_help_shows_command_shapes() -> None:
|
|
|
79
74
|
assert "human PROFILE_URL" in result.stdout
|
|
80
75
|
assert "company COMPANY" in result.stdout
|
|
81
76
|
assert "skill SKILL" in result.stdout
|
|
77
|
+
assert "auth import --browser BROWSER" in result.stdout
|
|
78
|
+
assert "auth status" in result.stdout
|
|
82
79
|
assert f"human {RUSLAN_URL}" in result.stdout
|
|
83
80
|
assert "company https://www.linkedin.com/company/ozon-tech" in result.stdout
|
|
84
81
|
assert "skill Flutter" in result.stdout
|
|
@@ -138,13 +135,15 @@ def test_company_default_limit_is_five() -> None:
|
|
|
138
135
|
assert args.limit == 5
|
|
139
136
|
|
|
140
137
|
|
|
141
|
-
def
|
|
138
|
+
def test_company_accepts_filter_query() -> None:
|
|
139
|
+
args = cli.parse_company_args(["Avito", "--filter", "Android Developer"])
|
|
140
|
+
|
|
141
|
+
assert args.filter == "Android Developer"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_default_cache_uses_user_directory() -> None:
|
|
142
145
|
args = cli.parse_company_args(["https://www.linkedin.com/company/binance/"])
|
|
143
146
|
|
|
144
|
-
assert (
|
|
145
|
-
args.cookie_file
|
|
146
|
-
== Path.home() / ".config" / "warmpath" / "linkedin.cookies"
|
|
147
|
-
)
|
|
148
147
|
assert args.cache_dir == Path.home() / ".cache" / "warmpath"
|
|
149
148
|
|
|
150
149
|
|
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
from warmpath.cli import (
|
|
2
|
+
candidate_matches_filter,
|
|
2
3
|
company_path_candidate,
|
|
3
4
|
connection_result_row,
|
|
4
5
|
render_company_path_result,
|
|
5
6
|
)
|
|
6
7
|
|
|
7
8
|
|
|
9
|
+
def test_candidate_filter_matches_visible_profile_fields_case_insensitively() -> None:
|
|
10
|
+
candidate = {
|
|
11
|
+
"target": {
|
|
12
|
+
"name": "Ada Lovelace",
|
|
13
|
+
"jobtitle": "Senior Android Developer",
|
|
14
|
+
"location": "London, United Kingdom",
|
|
15
|
+
"url": "https://www.linkedin.com/in/ada-lovelace/",
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assert candidate_matches_filter(candidate, "android developer")
|
|
20
|
+
assert candidate_matches_filter(candidate, "ADA")
|
|
21
|
+
assert candidate_matches_filter(candidate, "united kingdom")
|
|
22
|
+
assert not candidate_matches_filter(candidate, "iOS")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_candidate_filter_normalizes_whitespace() -> None:
|
|
26
|
+
candidate = {"target": {"jobtitle": "Senior Android Developer"}}
|
|
27
|
+
|
|
28
|
+
assert candidate_matches_filter(candidate, "android developer")
|
|
29
|
+
|
|
30
|
+
|
|
8
31
|
def test_connection_result_row_extracts_visible_mutual_connection_names() -> None:
|
|
9
32
|
row = connection_result_row(
|
|
10
33
|
{
|