warmpath 0.1.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.
@@ -0,0 +1,7 @@
1
+ # Agent Instructions
2
+
3
+ ## Verification
4
+
5
+ - Run `task check` after important code or configuration changes.
6
+ - Report any failures from `task check`, or explain why it could not be run.
7
+ - Do not claim that verification succeeded unless `task check` completed successfully.
@@ -1,6 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: warmpath
3
- Version: 0.1.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
+ ```
@@ -0,0 +1,37 @@
1
+ version: 3
2
+
3
+ env:
4
+ UV_CACHE_DIR: .uv-cache
5
+
6
+ tasks:
7
+ typecheck:
8
+ run: once
9
+ desc: Type-check Python with Pyright.
10
+ cmd: uv run python -m pyright warmpath
11
+
12
+ test:
13
+ run: once
14
+ desc: Run Python unit and live integration tests.
15
+ cmd: uv run pytest
16
+
17
+ check:
18
+ run: once
19
+ deps:
20
+ - typecheck
21
+ - test
22
+
23
+ build:
24
+ desc: Build source and wheel distributions.
25
+ cmds:
26
+ - rm -rf dist
27
+ - UV_CACHE_DIR="${TMPDIR:-/tmp}/warmpath-uv-cache" uv build --no-sources
28
+
29
+ outdated:
30
+ desc: Print outdated project dependencies.
31
+ cmd: uv tree --outdated
32
+
33
+ release:
34
+ desc: Check, build, publish, smoke-test, and tag a committed version.
35
+ deps:
36
+ - check
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,21 +1,19 @@
1
1
  [project]
2
2
  name = "warmpath"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Local LinkedIn visible-connections scraper."
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
7
+ "browser-cookie3>=0.20.1",
7
8
  "open-linkedin-api>=2.3.1",
9
+ "platformdirs>=4.11.8",
8
10
  ]
9
11
 
10
12
  [dependency-groups]
11
- dev = [
12
- "pytest>=8.4.1",
13
- "pytest-xdist>=3.8.0",
14
- "pyright>=1.1.402",
15
- ]
13
+ dev = ["pytest>=8.4.1", "pytest-xdist>=3.8.0", "pyright>=1.1.411"]
16
14
 
17
15
  [tool.pytest.ini_options]
18
- addopts = "-n auto"
16
+ addopts = "-n auto --dist loadgroup"
19
17
  testpaths = ["tests"]
20
18
 
21
19
  [project.scripts]
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ PACKAGE="warmpath"
5
+ TOKEN_FILE="${PYPI_TOKEN_FILE:-.pypi-token}"
6
+ export UV_CACHE_DIR="${RELEASE_UV_CACHE_DIR:-${TMPDIR:-/tmp}/warmpath-uv-cache}"
7
+
8
+ if test "$#" -ne 0; then
9
+ echo "usage: $0" >&2
10
+ exit 2
11
+ fi
12
+
13
+ version="$(uv version --short)"
14
+ tag="v${version}"
15
+
16
+ if ! test -f "$TOKEN_FILE"; then
17
+ echo "$TOKEN_FILE is required for publishing." >&2
18
+ exit 1
19
+ fi
20
+
21
+ token="$(tr -d '\r\n\t ' < "$TOKEN_FILE")"
22
+ test -n "$token"
23
+ case "$token" in
24
+ pypi-*) ;;
25
+ *) echo "$TOKEN_FILE must contain a PyPI token." >&2; exit 1 ;;
26
+ esac
27
+
28
+ rm -rf dist
29
+ if test -n "$(git status --porcelain --untracked-files=all)"; then
30
+ echo "Working tree is not clean; commit before release." >&2
31
+ exit 1
32
+ fi
33
+
34
+ if git show-ref --verify --quiet "refs/tags/$tag"; then
35
+ echo "Tag $tag already exists locally." >&2
36
+ exit 1
37
+ fi
38
+
39
+ remote_tag="$(git ls-remote --tags origin "refs/tags/$tag")"
40
+ if test -n "$remote_tag"; then
41
+ echo "Tag $tag already exists on origin." >&2
42
+ exit 1
43
+ fi
44
+
45
+ echo "Building artifacts"
46
+ uv build --no-sources
47
+
48
+ sdist="dist/${PACKAGE}-${version}.tar.gz"
49
+ wheel="dist/${PACKAGE}-${version}-py3-none-any.whl"
50
+
51
+ echo "Verifying artifacts"
52
+ test -f "$sdist"
53
+ test -f "$wheel"
54
+
55
+ tar tf "$sdist" | grep -q "^${PACKAGE}-${version}/${PACKAGE}/cli.py$"
56
+ uv run python -m zipfile -l "$wheel" | grep -q "${PACKAGE}/cli.py"
57
+
58
+ if tar tf "$sdist" | grep -q '\.pypi-token'; then
59
+ echo ".pypi-token leaked into sdist." >&2
60
+ exit 1
61
+ fi
62
+
63
+ if tar tf "$sdist" | grep -q '\.uv-cache'; then
64
+ echo ".uv-cache leaked into sdist." >&2
65
+ exit 1
66
+ fi
67
+
68
+ if uv run python -m zipfile -l "$wheel" | grep -q '\.pypi-token'; then
69
+ echo ".pypi-token leaked into wheel." >&2
70
+ exit 1
71
+ fi
72
+
73
+ if uv run python -m zipfile -l "$wheel" | grep -q '\.uv-cache'; then
74
+ echo ".uv-cache leaked into wheel." >&2
75
+ exit 1
76
+ fi
77
+
78
+ echo "Publishing ${PACKAGE} ${version}"
79
+ UV_PUBLISH_TOKEN="$token" uv publish
80
+
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