warmpath 0.3.0__tar.gz → 0.3.2__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,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: warmpath
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Local LinkedIn visible-connections scraper.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: browser-cookie3>=0.20.1
@@ -0,0 +1,55 @@
1
+ ![Warmpath banner](warmpath.jpg)
2
+
3
+ # Warmpath
4
+
5
+ Find LinkedIn mutuals and warm paths using your logged-in browser session.
6
+
7
+ ## Usage
8
+
9
+ Log in to LinkedIn in a regular browser window, then import that session. Replace `chrome` with your browser's name if needed.
10
+
11
+ ```sh
12
+ # Import and check your browser session
13
+ uvx warmpath auth import --browser chrome
14
+ uvx warmpath auth status
15
+
16
+ # Find people who can introduce you to a company
17
+ uvx warmpath company HashiCorp
18
+ uvx warmpath company https://www.linkedin.com/company/hashicorp/ --max-degree 2 --limit 5
19
+
20
+ # Find reachable people with a skill
21
+ uvx warmpath skill Flutter
22
+ uvx warmpath skill Leadership --max-depth 2
23
+
24
+ # Find mutual connections with a person
25
+ uvx warmpath human https://www.linkedin.com/in/mitchellh/
26
+
27
+ # Refresh cached results
28
+ uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
29
+
30
+ # Show options for a command; also works with auth, skill, and human
31
+ uvx warmpath company --help
32
+ ```
33
+
34
+ ## Browser sessions
35
+
36
+ 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.
37
+
38
+ 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.
39
+
40
+ `auth status` fetches and prints the logged-in user's name from LinkedIn, along with the source browser, import time, cookie expiry, and storage location, without displaying cookie values. It requires a connection to LinkedIn and exits with an error if the user lookup fails. If the session expires or LinkedIn stops accepting it, log in again and repeat `auth import`.
41
+
42
+ ## Development
43
+
44
+ Browser cookie import uses [browser-cookie3](https://github.com/borisbabic/browser_cookie3).
45
+
46
+ [go-task](https://taskfile.dev/) runs the repository checks. Install it with Homebrew on macOS, or follow the [installation guide](https://taskfile.dev/docs/installation) for other platforms. Then verify the tool, install the Python development dependencies, and run the checks:
47
+
48
+ ```sh
49
+ # macOS
50
+ brew install go-task
51
+
52
+ task --version
53
+ uv sync
54
+ task check
55
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "warmpath"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Local LinkedIn visible-connections scraper."
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
@@ -6,6 +6,7 @@ from unittest.mock import Mock
6
6
  import pytest
7
7
  from requests import Request
8
8
  from requests.cookies import RequestsCookieJar
9
+ from requests.exceptions import ConnectionError, HTTPError, Timeout
9
10
 
10
11
  from warmpath import auth, cli
11
12
 
@@ -17,6 +18,17 @@ def auth_path(tmp_path, monkeypatch):
17
18
  return path
18
19
 
19
20
 
21
+ @pytest.fixture(autouse=True)
22
+ def profile_fetch(monkeypatch):
23
+ response = Mock()
24
+ response.json.return_value = {
25
+ "miniProfile": {"firstName": "Ada", "lastName": "Lovelace"},
26
+ }
27
+ fetch = Mock(return_value=response)
28
+ monkeypatch.setattr(cli.Linkedin, "_fetch", fetch)
29
+ return fetch
30
+
31
+
20
32
  @pytest.fixture
21
33
  def browser_cookies():
22
34
  jar = RequestsCookieJar()
@@ -50,6 +62,43 @@ def test_import_reads_only_the_selected_browser(browser, browser_cookies, monkey
50
62
  assert auth.load_auth().browser == browser
51
63
 
52
64
 
65
+ @pytest.mark.parametrize("secure", [0, 1])
66
+ def test_browser_cookie_integer_secure_flag_round_trips(secure, reader, browser_cookies, auth_path):
67
+ # browser-cookie3 passes Chrome's SQLite integer flags through to Cookie.
68
+ for cookie in list(browser_cookies):
69
+ browser_cookies.set_cookie(auth.browser_cookie3.create_cookie(
70
+ cookie.domain, cookie.path, secure, cookie.expires,
71
+ cookie.name, cookie.value, True,
72
+ ))
73
+
74
+ cli.main(["auth", "import", "--browser", "chrome"])
75
+ cli.main(["auth", "status"])
76
+
77
+ payload = json.loads(auth_path.read_text())
78
+ assert all(record["secure"] is bool(secure) for record in payload["cookies"])
79
+ assert auth.load_cookies().get_dict() == browser_cookies.get_dict()
80
+ assert all(cookie.secure is bool(secure) for cookie in auth.load_cookies())
81
+
82
+
83
+ @pytest.mark.parametrize("secure", [0, 1])
84
+ def test_existing_integer_secure_flags_load_without_reimport(secure, reader, auth_path):
85
+ auth.import_browser("chrome")
86
+ payload = json.loads(auth_path.read_text())
87
+ for record in payload["cookies"]:
88
+ record["secure"] = secure
89
+ auth_path.write_text(json.dumps(payload))
90
+ before = auth_path.read_bytes()
91
+ reader.reset_mock()
92
+
93
+ cli.main(["auth", "status"])
94
+ cookies = auth.load_cookies()
95
+
96
+ assert not auth.load_auth().missing_cookies()
97
+ assert all(cookie.secure is bool(secure) for cookie in cookies)
98
+ assert auth_path.read_bytes() == before
99
+ reader.assert_not_called()
100
+
101
+
53
102
  def test_import_filters_unrelated_expired_and_out_of_scope_cookies(reader, browser_cookies, auth_path):
54
103
  for name, domain, path, expires, value in [
55
104
  ("tracking", ".linkedin.com", "/", None, "unrelated-tracking-token"),
@@ -99,8 +148,9 @@ def test_imported_session_reaches_linkedin_with_csrf_and_cookie_attributes(reade
99
148
  assert "Cookie" not in session.prepare_request(Request("GET", "http://www.linkedin.com/")).headers
100
149
 
101
150
 
102
- def test_import_and_status_report_metadata_without_cookie_values(reader, capsys, auth_path):
151
+ def test_import_and_status_report_metadata_without_cookie_values(reader, capsys, auth_path, profile_fetch):
103
152
  cli.main(["auth", "import", "--browser", "Chrome"])
153
+ profile_fetch.assert_not_called()
104
154
  before = auth_path.read_bytes()
105
155
  reader.reset_mock()
106
156
  cli.main(["auth", "status"])
@@ -108,6 +158,7 @@ def test_import_and_status_report_metadata_without_cookie_values(reader, capsys,
108
158
  output = capsys.readouterr()
109
159
  assert output.err == ""
110
160
  assert output.out.count("Status: ready (local expiry check)") == 2
161
+ assert output.out.count("User: Ada Lovelace") == 1
111
162
  assert "Browser: chrome" in output.out
112
163
  assert "Imported:" in output.out
113
164
  assert str(auth_path) in output.out
@@ -117,6 +168,84 @@ def test_import_and_status_report_metadata_without_cookie_values(reader, capsys,
117
168
  assert "private-csrf-token" not in output.out
118
169
  assert auth_path.read_bytes() == before
119
170
  reader.assert_not_called()
171
+ profile_fetch.assert_called_once_with("/me", timeout=15, evade=cli.no_delay)
172
+
173
+
174
+ @pytest.mark.parametrize("profile,expected", [
175
+ ({"firstName": " Ada ", "lastName": " Lovelace "}, "Ada Lovelace"),
176
+ ({"firstName": "Адель", "lastName": "Низамутдинов"}, "Адель Низамутдинов"),
177
+ ({"firstName": "Ada"}, "Ada"),
178
+ ({"lastName": "Lovelace"}, "Lovelace"),
179
+ ])
180
+ def test_status_prints_the_logged_in_users_name(profile, expected, reader, profile_fetch, capsys):
181
+ auth.import_browser("chrome")
182
+ profile_fetch.return_value.json.return_value = {"miniProfile": profile}
183
+
184
+ cli.main(["auth", "status"])
185
+
186
+ assert f"User: {expected}\n" in capsys.readouterr().out
187
+
188
+
189
+ @pytest.mark.parametrize("payload", [
190
+ {}, None, [], {"miniProfile": None}, {"miniProfile": "private-linkedin-token"},
191
+ {"miniProfile": {"firstName": " ", "lastName": None}},
192
+ {"miniProfile": {"firstName": ["private-linkedin-token"], "lastName": 123}},
193
+ ])
194
+ def test_status_rejects_a_profile_without_a_name(payload, reader, profile_fetch, capsys):
195
+ auth.import_browser("chrome")
196
+ profile_fetch.return_value.json.return_value = payload
197
+
198
+ with pytest.raises(SystemExit) as error:
199
+ cli.main(["auth", "status"])
200
+
201
+ assert error.value.code == 2
202
+ output = capsys.readouterr()
203
+ assert "LinkedIn did not return a name" in output.err
204
+ assert "User:" not in output.out
205
+ assert "private-linkedin-token" not in output.out + output.err
206
+
207
+
208
+ @pytest.mark.parametrize("failure", ["connection", "timeout", "http", "json"])
209
+ def test_status_handles_failed_user_lookup_without_exposing_secrets(failure, reader, profile_fetch, capsys, auth_path):
210
+ auth.import_browser("chrome")
211
+ before = auth_path.read_bytes()
212
+ reader.reset_mock()
213
+ if failure == "connection":
214
+ profile_fetch.side_effect = ConnectionError("private-linkedin-token")
215
+ elif failure == "timeout":
216
+ profile_fetch.side_effect = Timeout("private-linkedin-token")
217
+ elif failure == "http":
218
+ profile_fetch.return_value.raise_for_status.side_effect = HTTPError("private-linkedin-token")
219
+ else:
220
+ profile_fetch.return_value.json.side_effect = ValueError("private-linkedin-token")
221
+
222
+ with pytest.raises(SystemExit) as error:
223
+ cli.main(["auth", "status"])
224
+
225
+ assert error.value.code == 2
226
+ output = capsys.readouterr()
227
+ assert "Could not fetch the logged-in LinkedIn user" in output.err
228
+ assert "warmpath auth import --browser" in output.err
229
+ assert "User:" not in output.out
230
+ assert "private-linkedin-token" not in output.out + output.err
231
+ assert auth_path.read_bytes() == before
232
+ reader.assert_not_called()
233
+
234
+
235
+ @pytest.mark.parametrize("status_code", [401, 403])
236
+ def test_status_reports_rejected_sessions(status_code, reader, profile_fetch, capsys):
237
+ auth.import_browser("chrome")
238
+ profile_fetch.return_value.status_code = status_code
239
+
240
+ with pytest.raises(SystemExit) as error:
241
+ cli.main(["auth", "status"])
242
+
243
+ assert error.value.code == 2
244
+ output = capsys.readouterr()
245
+ assert "LinkedIn rejected the saved session" in output.err
246
+ assert "warmpath auth import --browser" in output.err
247
+ assert "User:" not in output.out
248
+ profile_fetch.return_value.json.assert_not_called()
120
249
 
121
250
 
122
251
  @pytest.mark.skipif(os.name != "posix", reason="Unix file permissions")
@@ -215,6 +344,7 @@ def test_corrupt_store_has_reimport_guidance(content, auth_path, capsys):
215
344
 
216
345
  @pytest.mark.parametrize("field,value", [
217
346
  ("domain", "example.org"), ("path", "/jobs"), ("secure", "true"),
347
+ ("secure", 2), ("secure", -1), ("secure", 0.0), ("secure", 1.0), ("secure", None),
218
348
  ("expires", "tomorrow"), ("expires", 10**20),
219
349
  ("domain_specified", False), ("value", "token\r\nInjected: header"),
220
350
  ])
@@ -228,7 +358,7 @@ def test_invalid_stored_cookie_is_rejected(field, value, reader, auth_path):
228
358
  auth.load_cookies()
229
359
 
230
360
 
231
- def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsys):
361
+ def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsys, profile_fetch):
232
362
  session = auth.import_browser("chrome")
233
363
  next(cookie for cookie in session.cookies if cookie.name == "li_at").expires = 1
234
364
  auth.save_auth(session)
@@ -249,6 +379,7 @@ def test_expired_session_is_reported_and_rejected_without_reimport(reader, capsy
249
379
  assert api_error.value.code == 2
250
380
  assert "warmpath auth import --browser chrome" in capsys.readouterr().err
251
381
  reader.assert_not_called()
382
+ profile_fetch.assert_not_called()
252
383
 
253
384
 
254
385
  @pytest.mark.parametrize("arguments", [[], ["import"], ["import", "--browser", "unknown"], ["login"]])
@@ -616,7 +616,7 @@ wheels = [
616
616
 
617
617
  [[package]]
618
618
  name = "warmpath"
619
- version = "0.3.0"
619
+ version = "0.3.2"
620
620
  source = { editable = "." }
621
621
  dependencies = [
622
622
  { name = "browser-cookie3" },
@@ -93,7 +93,7 @@ def save_auth(session: AuthSession) -> None:
93
93
  "domain": cookie.domain,
94
94
  "domain_specified": cookie.domain_specified,
95
95
  "path": cookie.path,
96
- "secure": cookie.secure,
96
+ "secure": bool(cookie.secure),
97
97
  "expires": cookie.expires,
98
98
  }
99
99
  for cookie in session.cookies
@@ -173,7 +173,9 @@ def load_auth() -> AuthSession:
173
173
  not isinstance(record.get(key), str)
174
174
  for key in ("name", "value", "domain", "path")
175
175
  )
176
- or type(record.get("secure")) is not bool
176
+ # Older imports saved browser-cookie3's integer 0/1 Secure flag.
177
+ or type(record.get("secure")) not in (bool, int)
178
+ or record["secure"] not in (0, 1)
177
179
  or type(record.get("domain_specified")) is not bool
178
180
  or (
179
181
  record.get("expires") is not None
@@ -186,7 +188,7 @@ def load_auth() -> AuthSession:
186
188
  value=record["value"],
187
189
  domain=record["domain"],
188
190
  path=record["path"],
189
- secure=record["secure"],
191
+ secure=bool(record["secure"]),
190
192
  expires=record.get("expires"),
191
193
  )
192
194
  cookie.domain_specified = record["domain_specified"]
@@ -9,6 +9,7 @@ from typing import Any, Callable, NoReturn
9
9
  from urllib.parse import unquote, urlparse
10
10
 
11
11
  from open_linkedin_api import Linkedin
12
+ from requests.exceptions import RequestException
12
13
 
13
14
  from warmpath import auth
14
15
 
@@ -109,6 +110,35 @@ def build_api() -> Any:
109
110
  return api
110
111
 
111
112
 
113
+ def logged_in_user_name(api: Any) -> str:
114
+ try:
115
+ response = api._fetch("/me", timeout=15)
116
+ if response.status_code in (401, 403):
117
+ raise auth.AuthError(
118
+ "LinkedIn rejected the saved session. Sign in to LinkedIn, "
119
+ f"then reimport your session. {auth.IMPORT_HINT}"
120
+ )
121
+ response.raise_for_status()
122
+ data = response.json()
123
+ except (RequestException, ValueError) as exc:
124
+ raise auth.AuthError(
125
+ "Could not fetch the logged-in LinkedIn user. Check your connection "
126
+ f"and try again, or reimport your session. {auth.IMPORT_HINT}"
127
+ ) from exc
128
+
129
+ profile = data.get("miniProfile") if isinstance(data, dict) else None
130
+ name = ""
131
+ if isinstance(profile, dict):
132
+ name = " ".join(
133
+ value.strip()
134
+ for key in ("firstName", "lastName")
135
+ if isinstance(value := profile.get(key), str) and value.strip()
136
+ )
137
+ if not name:
138
+ raise auth.AuthError("LinkedIn did not return a name for the logged-in user.")
139
+ return name
140
+
141
+
112
142
  def profile_urn_id(api: Any, public_id: str) -> str:
113
143
  urn_id = profile_urn_id_from_html(api, public_id)
114
144
  if urn_id:
@@ -1743,7 +1773,7 @@ def parse_auth_args(argv: list[str]) -> argparse.Namespace:
1743
1773
  choices=auth.BROWSERS,
1744
1774
  help="Browser where you are logged in to LinkedIn.",
1745
1775
  )
1746
- commands.add_parser("status", help="Check the saved session and cookie expiry locally.")
1776
+ commands.add_parser("status", help="Show the logged-in user and saved session details.")
1747
1777
  return parser.parse_args(argv)
1748
1778
 
1749
1779
 
@@ -1757,6 +1787,8 @@ def run_auth_command(args: argparse.Namespace) -> None:
1757
1787
  print(auth.render_status(session))
1758
1788
  if session.missing_cookies():
1759
1789
  raise SystemExit(1)
1790
+ if args.auth_command == "status":
1791
+ print(f"User: {logged_in_user_name(build_api())}")
1760
1792
  except auth.AuthError as exc:
1761
1793
  fail(str(exc), 2)
1762
1794
 
@@ -1771,7 +1803,7 @@ def parse_main_args(argv: list[str]) -> argparse.Namespace:
1771
1803
  Import your LinkedIn session from a browser.
1772
1804
 
1773
1805
  auth status
1774
- Check the saved session and cookie expiry locally.
1806
+ Show the logged-in user and saved session details.
1775
1807
 
1776
1808
  human PROFILE_URL
1777
1809
  Print mutual LinkedIn connections for a profile URL.
warmpath-0.3.0/README.md DELETED
@@ -1,80 +0,0 @@
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
- ```
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes