warmpath 0.3.4__tar.gz → 0.3.5__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.4
3
+ Version: 0.3.5
4
4
  Summary: Local LinkedIn visible-connections scraper.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: browser-cookie3>=0.20.1
@@ -36,6 +36,9 @@ uvx warmpath human https://www.linkedin.com/in/mitchellh/ --refresh-cache
36
36
  uvx warmpath company --help
37
37
  ```
38
38
 
39
+ `company` prints mutual contacts for second-degree candidates, looking them up
40
+ when LinkedIn's company search does not include their names.
41
+
39
42
  `companies` walks the complete 1st-degree connections list, looks up current
40
43
  employers, and deduplicates by company ID. It includes multiple current employers
41
44
  and keeps unlinked employer names; `--urls` prints only known LinkedIn company
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "warmpath"
3
- version = "0.3.4"
3
+ version = "0.3.5"
4
4
  description = "Local LinkedIn visible-connections scraper."
5
5
  requires-python = ">=3.10"
6
6
  dependencies = [
@@ -1,3 +1,8 @@
1
+ from unittest.mock import Mock
2
+
3
+ import pytest
4
+
5
+ from warmpath import cli
1
6
  from warmpath.cli import (
2
7
  candidate_matches_filter,
3
8
  company_path_candidate,
@@ -106,6 +111,98 @@ def test_second_degree_candidate_renders_visible_mutual_connections() -> None:
106
111
  assert "unknown introducer" not in rendered
107
112
 
108
113
 
114
+ @pytest.mark.parametrize("mutual_count", [None, 3])
115
+ @pytest.mark.parametrize("found_mutual", [True, False])
116
+ def test_company_looks_up_missing_mutual_contacts(
117
+ tmp_path, monkeypatch, mutual_count, found_mutual
118
+ ) -> None:
119
+ monkeypatch.setattr(cli, "resolve_company", Mock(return_value=(
120
+ {"name": "Acme", "urn_id": "company-id"}, []
121
+ )))
122
+ monkeypatch.setattr(cli, "fetch_company_people", Mock(side_effect=lambda *args: (
123
+ [] if args[3] == 1 else [{
124
+ "name": "Employee",
125
+ "urn_id": "employee-id",
126
+ "distance": "DISTANCE_2",
127
+ "mutual_count": mutual_count,
128
+ "mutuals_truncated": True,
129
+ "_search_source": "search.current_company",
130
+ }]
131
+ )))
132
+ api = Mock()
133
+ api.search.return_value = [{
134
+ "entityUrn": "urn:li:fsd_profile:contact-id",
135
+ "title": {"text": "Ada Lovelace"},
136
+ "navigationUrl": "https://www.linkedin.com/in/ada-lovelace/",
137
+ "entityCustomTrackingInfo": {"memberDistance": "DISTANCE_1"},
138
+ }] if found_mutual else []
139
+
140
+ result = cli.find_company_path_candidates(
141
+ api, "Acme", 2, 5, None, 5, tmp_path, False
142
+ )
143
+
144
+ api.search.assert_called_once_with({
145
+ "filters": (
146
+ "List((key:resultType,value:List(PEOPLE)),"
147
+ "(key:connectionOf,value:List(employee-id)),"
148
+ "(key:network,value:List(F)))"
149
+ )
150
+ }, limit=mutual_count or cli.DEFAULT_MAX_MUTUAL_CONNECTIONS)
151
+ candidate = result["candidates"][0]
152
+ rendered = render_company_path_result(result)
153
+ assert "Employee" in rendered
154
+ assert candidate["evidence"]["source"] == "search.current_company"
155
+ if found_mutual:
156
+ expected = "Mutuals (3): Ada Lovelace, +2 more" if mutual_count else "Mutuals (1): Ada Lovelace"
157
+ assert expected in rendered
158
+ assert candidate["path_status"] == "partially_resolved"
159
+ assert candidate["path"][1]["url"] == "https://www.linkedin.com/in/ada-lovelace/"
160
+ else:
161
+ assert "Mutuals" not in rendered
162
+ assert candidate["path_status"] == "unresolved"
163
+ assert candidate["mutual_count"] == mutual_count
164
+ assert candidate["mutuals_truncated"] is True
165
+
166
+ # Repeating a company search reuses its mutual-contact lookup.
167
+ assert cli.find_company_path_candidates(
168
+ api, "Acme", 2, 5, None, 5, tmp_path, False
169
+ ) == result
170
+ api.search.assert_called_once()
171
+
172
+
173
+ def test_company_skips_mutual_lookups_for_direct_known_and_unprinted_candidates(
174
+ tmp_path, monkeypatch
175
+ ) -> None:
176
+ monkeypatch.setattr(cli, "resolve_company", Mock(return_value=(
177
+ {"name": "Acme", "urn_id": "company-id"}, []
178
+ )))
179
+ direct = {"name": "Direct", "urn_id": "direct-id", "jobtitle": "Engineer"}
180
+ second_degree = [
181
+ {"name": "Filtered out", "urn_id": "filtered-id", "jobtitle": "Recruiter"},
182
+ {
183
+ "name": "Known mutual",
184
+ "urn_id": "known-id",
185
+ "jobtitle": "Engineer",
186
+ "mutual_connections": [{"name": "Ada Lovelace"}],
187
+ },
188
+ {"name": "Over limit", "urn_id": "limited-id", "jobtitle": "Engineer"},
189
+ ]
190
+ monkeypatch.setattr(cli, "fetch_company_people", Mock(
191
+ side_effect=lambda *args: [direct] if args[3] == 1 else second_degree
192
+ ))
193
+ api = Mock()
194
+
195
+ result = cli.find_company_path_candidates(
196
+ api, "Acme", 2, 2, "Engineer", 5, tmp_path, False
197
+ )
198
+
199
+ assert [candidate["target"]["name"] for candidate in result["candidates"]] == [
200
+ "Direct", "Known mutual"
201
+ ]
202
+ assert "Mutuals (1): Ada Lovelace" in render_company_path_result(result)
203
+ api.search.assert_not_called()
204
+
205
+
109
206
  def test_second_degree_candidate_without_mutuals_keeps_unresolved_status() -> None:
110
207
  row = {
111
208
  "name": "Ruslan Gilemzianov",
@@ -768,7 +768,7 @@ wheels = [
768
768
 
769
769
  [[package]]
770
770
  name = "warmpath"
771
- version = "0.3.4"
771
+ version = "0.3.5"
772
772
  source = { editable = "." }
773
773
  dependencies = [
774
774
  { name = "browser-cookie3" },
@@ -682,6 +682,9 @@ def company_path_candidate(row: dict[str, Any], degree: int) -> dict[str, Any]:
682
682
  "degree": degree,
683
683
  "path_status": "unresolved",
684
684
  "target": target,
685
+ "mutual_connections": mutual_connections,
686
+ "mutual_count": mutual_count,
687
+ "mutuals_truncated": mutuals_truncated,
685
688
  "path": [
686
689
  {"role": "me"},
687
690
  {"role": "unknown_introducer"},
@@ -1190,6 +1193,23 @@ def find_company_path_candidates(
1190
1193
 
1191
1194
  candidates.sort(key=candidate_score)
1192
1195
  candidates = candidates[:limit]
1196
+ for index, candidate in enumerate(candidates):
1197
+ if candidate["degree"] != 2 or candidate_mutual_names(candidate):
1198
+ continue
1199
+ row = {
1200
+ **candidate["target"],
1201
+ "mutual_count": candidate.get("mutual_count"),
1202
+ "mutuals_truncated": candidate.get("mutuals_truncated", False),
1203
+ "_search_source": candidate["evidence"]["source"],
1204
+ }
1205
+ row = enrich_row_with_mutual_connections(
1206
+ api,
1207
+ row,
1208
+ cache_dir,
1209
+ refresh_cache,
1210
+ )
1211
+ candidates[index] = company_path_candidate(row, 2)
1212
+
1193
1213
  direct_count = sum(1 for candidate in candidates if candidate["degree"] == 1)
1194
1214
  second_count = sum(1 for candidate in candidates if candidate["degree"] == 2)
1195
1215
  resolved_count = sum(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes