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.
@@ -1,6 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: warmpath
3
- Version: 0.2.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
@@ -0,0 +1,80 @@
1
+ ![Warmpath banner](warmpath.jpg)
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
+ ```
@@ -31,7 +31,7 @@ tasks:
31
31
  cmd: uv tree --outdated
32
32
 
33
33
  release:
34
- desc: Check, build, tag, publish, and smoke-test a committed version.
34
+ desc: Check, build, publish, smoke-test, and tag a committed version.
35
35
  deps:
36
36
  - check
37
37
  cmd: scripts/release.sh
@@ -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 cookie file, resolves the cache directory, calls the skill search pipeline, and prints the rendered result.
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.2.0"
3
+ version = "0.3.0"
4
4
  description = "Local LinkedIn visible-connections scraper."
5
5
  requires-python = ">=3.10"
6
- dependencies = ["open-linkedin-api>=2.3.1"]
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 test_default_paths_use_user_directories() -> None:
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
  {