warmpath 0.1.0__tar.gz → 0.2.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,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: warmpath
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Local LinkedIn visible-connections scraper.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: open-linkedin-api>=2.3.1
@@ -17,6 +17,27 @@ Find LinkedIn mutuals and warm paths using your logged-in LinkedIn cookies.
17
17
 
18
18
  Warmpath needs the `li_at` and `JSESSIONID` cookies. Keep this file private; it lives outside the repository.
19
19
 
20
+ ## Development
21
+
22
+ [go-task](https://taskfile.dev/) is required to run the repository checks. Install it with Homebrew on macOS:
23
+
24
+ ```sh
25
+ brew install go-task
26
+ ```
27
+
28
+ 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:
29
+
30
+ ```sh
31
+ task --version
32
+ ```
33
+
34
+ Then install the Python development dependencies and run the full check suite:
35
+
36
+ ```sh
37
+ uv sync
38
+ task check
39
+ ```
40
+
20
41
  ## Usage
21
42
 
22
43
  ### Company
@@ -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, tag, publish, and smoke-test a committed version.
35
+ deps:
36
+ - check
37
+ cmd: scripts/release.sh
@@ -1,21 +1,15 @@
1
1
  [project]
2
2
  name = "warmpath"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Local LinkedIn visible-connections scraper."
5
5
  requires-python = ">=3.10"
6
- dependencies = [
7
- "open-linkedin-api>=2.3.1",
8
- ]
6
+ dependencies = ["open-linkedin-api>=2.3.1"]
9
7
 
10
8
  [dependency-groups]
11
- dev = [
12
- "pytest>=8.4.1",
13
- "pytest-xdist>=3.8.0",
14
- "pyright>=1.1.402",
15
- ]
9
+ dev = ["pytest>=8.4.1", "pytest-xdist>=3.8.0", "pyright>=1.1.411"]
16
10
 
17
11
  [tool.pytest.ini_options]
18
- addopts = "-n auto"
12
+ addopts = "-n auto --dist loadgroup"
19
13
  testpaths = ["tests"]
20
14
 
21
15
  [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 "Tagging ${PACKAGE} ${version}"
79
+ git tag -a "$tag" -m "${PACKAGE} ${version}"
80
+ git push origin "$tag"
81
+
82
+ echo "Publishing ${PACKAGE} ${version}"
83
+ UV_PUBLISH_TOKEN="$token" uv publish
84
+
85
+ echo "Smoke-testing uvx ${PACKAGE}"
86
+ uvx --refresh-package "$PACKAGE" "$PACKAGE" --help
@@ -0,0 +1,219 @@
1
+ import subprocess
2
+ import sys
3
+ from importlib.metadata import version as distribution_version
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from warmpath import cli
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ RUSLAN_URL = "https://www.linkedin.com/in/ruslan-gilemzianov/"
13
+ VIOLETTA_URL = "https://www.linkedin.com/in/violetta-shmatkova-844a0986/"
14
+ TIMUR_URL = "https://www.linkedin.com/in/timur-pokayonkov/"
15
+
16
+
17
+ def run_cli(*args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]:
18
+ return subprocess.run(
19
+ [
20
+ sys.executable,
21
+ "-m",
22
+ "warmpath.cli",
23
+ *args,
24
+ ],
25
+ cwd=ROOT,
26
+ text=True,
27
+ capture_output=True,
28
+ timeout=timeout,
29
+ check=False,
30
+ )
31
+
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
+ def run_live_cli(
41
+ tmp_path: Path,
42
+ *args: str,
43
+ timeout: int = 180,
44
+ ) -> subprocess.CompletedProcess[str]:
45
+ return run_cli(
46
+ *args,
47
+ "--cache-dir",
48
+ str(tmp_path),
49
+ "--cookie-file",
50
+ str(linkedin_cookie_file()),
51
+ "--refresh-cache",
52
+ timeout=timeout,
53
+ )
54
+
55
+
56
+ def require_live_linkedin_result(result: subprocess.CompletedProcess[str]) -> None:
57
+ unavailable_errors = (
58
+ "ConnectionError",
59
+ "JSONDecodeError",
60
+ "NameResolutionError",
61
+ "nodename nor servname provided",
62
+ )
63
+ if result.returncode != 0 and any(
64
+ error in result.stderr for error in unavailable_errors
65
+ ):
66
+ pytest.skip("LinkedIn API is unavailable")
67
+
68
+ assert result.returncode == 0, result.stderr
69
+ if "No reachable employees found" in result.stdout:
70
+ pytest.skip("LinkedIn company search returned no live results")
71
+ if "No reachable profiles found" in result.stdout:
72
+ pytest.skip("LinkedIn skill search returned no live results")
73
+
74
+
75
+ def test_top_level_help_shows_command_shapes() -> None:
76
+ result = run_cli("--help")
77
+
78
+ assert result.returncode == 0
79
+ assert "human PROFILE_URL" in result.stdout
80
+ assert "company COMPANY" in result.stdout
81
+ assert "skill SKILL" in result.stdout
82
+ assert f"human {RUSLAN_URL}" in result.stdout
83
+ assert "company https://www.linkedin.com/company/ozon-tech" in result.stdout
84
+ assert "skill Flutter" in result.stdout
85
+ assert "--version" in result.stdout
86
+
87
+
88
+ def test_top_level_version_uses_distribution_metadata() -> None:
89
+ result = run_cli("--version")
90
+
91
+ assert result.returncode == 0
92
+ assert result.stdout == f"warmpath {distribution_version('warmpath')}\n"
93
+ assert result.stderr == ""
94
+
95
+
96
+ def test_profile_flag_is_not_a_top_level_command() -> None:
97
+ result = run_cli("--profile", "mitchellh")
98
+
99
+ assert result.returncode != 0
100
+ assert "unrecognized arguments" in result.stderr
101
+
102
+
103
+ def test_connections_is_not_a_command() -> None:
104
+ result = run_cli("connections", "--help")
105
+
106
+ assert result.returncode != 0
107
+ assert "Unknown command: connections" in result.stderr
108
+
109
+
110
+ def test_human_is_the_profile_mutuals_command() -> None:
111
+ result = run_cli("human", "--help")
112
+
113
+ assert result.returncode == 0
114
+ assert "profile URL" in result.stdout
115
+
116
+
117
+ def test_company_is_the_company_command() -> None:
118
+ result = run_cli("company", "--help")
119
+
120
+ assert result.returncode == 0
121
+ assert "LinkedIn /company/ URL" in result.stdout
122
+ assert "Default: 5." in result.stdout
123
+ assert "company-path" not in result.stdout
124
+
125
+
126
+ def test_skill_is_the_skill_command() -> None:
127
+ result = run_cli("skill", "--help")
128
+
129
+ assert result.returncode == 0
130
+ assert "skill name to search for" in result.stdout
131
+ assert "Default: 2." in result.stdout
132
+ assert "Default: 5." in result.stdout
133
+
134
+
135
+ def test_company_default_limit_is_five() -> None:
136
+ args = cli.parse_company_args(["https://www.linkedin.com/company/binance/"])
137
+
138
+ assert args.limit == 5
139
+
140
+
141
+ def test_default_paths_use_user_directories() -> None:
142
+ args = cli.parse_company_args(["https://www.linkedin.com/company/binance/"])
143
+
144
+ assert (
145
+ args.cookie_file
146
+ == Path.home() / ".config" / "warmpath" / "linkedin.cookies"
147
+ )
148
+ assert args.cache_dir == Path.home() / ".cache" / "warmpath"
149
+
150
+
151
+ def test_resolve_path_expands_home() -> None:
152
+ assert cli.resolve_path(Path("~/warmpath-test")) == Path.home() / "warmpath-test"
153
+
154
+
155
+ @pytest.mark.xdist_group(name="linkedin")
156
+ def test_company_yadro_excludes_out_of_network_profile(tmp_path) -> None:
157
+ result = run_live_cli(
158
+ tmp_path,
159
+ "company",
160
+ "yadro",
161
+ "--limit",
162
+ "10",
163
+ "--max-degree",
164
+ "2",
165
+ timeout=120,
166
+ )
167
+
168
+ require_live_linkedin_result(result)
169
+ assert "https://www.linkedin.com/in/egorkazachkov/" not in result.stdout
170
+
171
+
172
+ @pytest.mark.xdist_group(name="linkedin")
173
+ def test_company_avito_large_search_includes_expected_second_degree_profile(
174
+ tmp_path,
175
+ ) -> None:
176
+ result = run_live_cli(
177
+ tmp_path,
178
+ "company",
179
+ "Avito",
180
+ "--limit",
181
+ "500",
182
+ timeout=240,
183
+ )
184
+
185
+ require_live_linkedin_result(result)
186
+ assert VIOLETTA_URL in result.stdout
187
+
188
+
189
+ def test_skill_default_max_depth_is_two() -> None:
190
+ args = cli.parse_skill_args(["Flutter"])
191
+
192
+ assert args.max_depth == 2
193
+
194
+
195
+ def test_skill_default_limit_is_five() -> None:
196
+ args = cli.parse_skill_args(["Flutter"])
197
+
198
+ assert args.limit == 5
199
+
200
+
201
+ def test_company_path_is_not_a_command() -> None:
202
+ result = run_cli("company-path", "--help")
203
+
204
+ assert result.returncode != 0
205
+ assert "Unknown command: company-path" in result.stderr
206
+
207
+
208
+ @pytest.mark.xdist_group(name="linkedin")
209
+ def test_skill_leadership_large_search_includes_expected_profile(tmp_path) -> None:
210
+ result = run_live_cli(
211
+ tmp_path,
212
+ "skill",
213
+ "Leadership",
214
+ "--limit",
215
+ "25",
216
+ )
217
+
218
+ require_live_linkedin_result(result)
219
+ assert TIMUR_URL in result.stdout
@@ -1,7 +1,6 @@
1
1
  from warmpath.cli import (
2
2
  company_path_candidate,
3
3
  connection_result_row,
4
- fetch_mutual_connection_rows,
5
4
  render_company_path_result,
6
5
  )
7
6
 
@@ -84,55 +83,6 @@ def test_second_degree_candidate_renders_visible_mutual_connections() -> None:
84
83
  assert "unknown introducer" not in rendered
85
84
 
86
85
 
87
- def test_fetch_mutual_connection_rows_searches_first_degree_connections(
88
- tmp_path,
89
- ) -> None:
90
- class FakeApi:
91
- params = None
92
- limit = None
93
-
94
- def search(self, params, limit):
95
- self.params = params
96
- self.limit = limit
97
- return [
98
- {
99
- "entityUrn": "urn:li:fsd_profile:ACoAACeBStkBoUxCGfrnXFevZJkQ-UX6eHu4deU",
100
- "entityCustomTrackingInfo": {"memberDistance": "DISTANCE_1"},
101
- "title": {"text": "Anastasiia Krivobokova"},
102
- "primarySubtitle": {"text": "Senior HR Business Partner"},
103
- "secondarySubtitle": {"text": "Amsterdam"},
104
- "navigationUrl": "https://www.linkedin.com/in/anastasiaandreewnaa/",
105
- },
106
- {
107
- "entityUrn": "urn:li:fsd_profile:ACoAACJd_TgBcn1VPQemkT4e3qsSPkR9WjEYhy8",
108
- "entityCustomTrackingInfo": {"memberDistance": "DISTANCE_1"},
109
- "title": {"text": "Andrey Zhuchkov"},
110
- "primarySubtitle": {"text": "/"},
111
- "secondarySubtitle": {"text": "Russia"},
112
- "navigationUrl": "https://www.linkedin.com/in/a-zhuchkov/",
113
- },
114
- ]
115
-
116
- api = FakeApi()
117
- rows = fetch_mutual_connection_rows(
118
- api,
119
- "ACoAABaR-MoBLHrbfL3jmaglgQoONjPGWrfmTSE",
120
- limit=2,
121
- cache_dir=tmp_path,
122
- refresh_cache=True,
123
- )
124
-
125
- assert "connectionOf,value:List(ACoAABaR-MoBLHrbfL3jmaglgQoONjPGWrfmTSE)" in api.params[
126
- "filters"
127
- ]
128
- assert "network,value:List(F)" in api.params["filters"]
129
- assert api.limit == 2
130
- assert [row["name"] for row in rows] == [
131
- "Anastasiia Krivobokova",
132
- "Andrey Zhuchkov",
133
- ]
134
-
135
-
136
86
  def test_second_degree_candidate_without_mutuals_keeps_unresolved_status() -> None:
137
87
  row = {
138
88
  "name": "Ruslan Gilemzianov",
@@ -143,7 +143,7 @@ name = "exceptiongroup"
143
143
  version = "1.3.1"
144
144
  source = { registry = "https://pypi.org/simple" }
145
145
  dependencies = [
146
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
146
+ { name = "typing-extensions" },
147
147
  ]
148
148
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
149
149
  wheels = [
@@ -315,15 +315,15 @@ wheels = [
315
315
 
316
316
  [[package]]
317
317
  name = "pyright"
318
- version = "1.1.410"
318
+ version = "1.1.411"
319
319
  source = { registry = "https://pypi.org/simple" }
320
320
  dependencies = [
321
321
  { name = "nodeenv" },
322
322
  { name = "typing-extensions" },
323
323
  ]
324
- sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" }
324
+ sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
325
325
  wheels = [
326
- { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" },
326
+ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
327
327
  ]
328
328
 
329
329
  [[package]]
@@ -455,7 +455,7 @@ wheels = [
455
455
 
456
456
  [[package]]
457
457
  name = "warmpath"
458
- version = "0.1.0"
458
+ version = "0.2.0"
459
459
  source = { editable = "." }
460
460
  dependencies = [
461
461
  { name = "open-linkedin-api" },
@@ -473,7 +473,7 @@ requires-dist = [{ name = "open-linkedin-api", specifier = ">=2.3.1" }]
473
473
 
474
474
  [package.metadata.requires-dev]
475
475
  dev = [
476
- { name = "pyright", specifier = ">=1.1.402" },
476
+ { name = "pyright", specifier = ">=1.1.411" },
477
477
  { name = "pytest", specifier = ">=8.4.1" },
478
478
  { name = "pytest-xdist", specifier = ">=3.8.0" },
479
479
  ]