async_linkedinlib 0.2.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. async_linkedinlib/__init__.py +187 -0
  2. async_linkedinlib/__main__.py +3 -0
  3. async_linkedinlib/auth/__init__.py +5 -0
  4. async_linkedinlib/auth/service.py +77 -0
  5. async_linkedinlib/cli.py +37 -0
  6. async_linkedinlib/client.py +93 -0
  7. async_linkedinlib/config/__init__.py +6 -0
  8. async_linkedinlib/config/options.py +15 -0
  9. async_linkedinlib/config/profile.py +56 -0
  10. async_linkedinlib/exceptions.py +75 -0
  11. async_linkedinlib/models/__init__.py +155 -0
  12. async_linkedinlib/models/_json.py +65 -0
  13. async_linkedinlib/models/comments.py +145 -0
  14. async_linkedinlib/models/content.py +207 -0
  15. async_linkedinlib/models/feed.py +124 -0
  16. async_linkedinlib/models/jobs.py +215 -0
  17. async_linkedinlib/models/jobs_apply.py +424 -0
  18. async_linkedinlib/models/member.py +14 -0
  19. async_linkedinlib/models/pagination.py +52 -0
  20. async_linkedinlib/models/posts.py +141 -0
  21. async_linkedinlib/models/posts_results.py +120 -0
  22. async_linkedinlib/models/search.py +307 -0
  23. async_linkedinlib/models/search_filters.py +339 -0
  24. async_linkedinlib/models/search_jobs.py +213 -0
  25. async_linkedinlib/py.typed +0 -0
  26. async_linkedinlib/resources/__init__.py +12 -0
  27. async_linkedinlib/resources/account.py +35 -0
  28. async_linkedinlib/resources/comments/__init__.py +5 -0
  29. async_linkedinlib/resources/comments/resource.py +227 -0
  30. async_linkedinlib/resources/comments/urns.py +43 -0
  31. async_linkedinlib/resources/feed/__init__.py +5 -0
  32. async_linkedinlib/resources/feed/queries.py +57 -0
  33. async_linkedinlib/resources/feed/resource.py +225 -0
  34. async_linkedinlib/resources/feed/saved.py +49 -0
  35. async_linkedinlib/resources/feed/urns.py +68 -0
  36. async_linkedinlib/resources/jobs/__init__.py +5 -0
  37. async_linkedinlib/resources/jobs/answers.py +239 -0
  38. async_linkedinlib/resources/jobs/resource.py +212 -0
  39. async_linkedinlib/resources/jobs/upload.py +78 -0
  40. async_linkedinlib/resources/mentions.py +58 -0
  41. async_linkedinlib/resources/paging.py +88 -0
  42. async_linkedinlib/resources/posts/__init__.py +6 -0
  43. async_linkedinlib/resources/posts/media.py +173 -0
  44. async_linkedinlib/resources/posts/payloads.py +98 -0
  45. async_linkedinlib/resources/posts/resource.py +254 -0
  46. async_linkedinlib/resources/reactions.py +89 -0
  47. async_linkedinlib/resources/search/__init__.py +5 -0
  48. async_linkedinlib/resources/search/queries.py +79 -0
  49. async_linkedinlib/resources/search/results.py +85 -0
  50. async_linkedinlib/resources/search/search.py +284 -0
  51. async_linkedinlib/resources/threads.py +30 -0
  52. async_linkedinlib/session/__init__.py +5 -0
  53. async_linkedinlib/session/cookies.py +52 -0
  54. async_linkedinlib/session/lease.py +30 -0
  55. async_linkedinlib/session/state.py +129 -0
  56. async_linkedinlib/transport/__init__.py +6 -0
  57. async_linkedinlib/transport/http.py +183 -0
  58. async_linkedinlib/transport/restli.py +46 -0
  59. async_linkedinlib/transport/voyager.py +151 -0
  60. async_linkedinlib-0.2.0.dist-info/METADATA +478 -0
  61. async_linkedinlib-0.2.0.dist-info/RECORD +64 -0
  62. async_linkedinlib-0.2.0.dist-info/WHEEL +4 -0
  63. async_linkedinlib-0.2.0.dist-info/entry_points.txt +2 -0
  64. async_linkedinlib-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,187 @@
1
+ """Async LinkedIn client: sessions, feed, posts, comments, search and jobs (Voyager API)."""
2
+
3
+ from .client import LinkedIn # Async client and resource lifecycle.
4
+ from .config import ClientOptions # Connection settings.
5
+ from .config import ClientProfile # Versioned app metadata.
6
+ from .exceptions import AuthenticationError # Rejected or expired credentials.
7
+ from .exceptions import ChallengeRequired # Verification required in the official app.
8
+ from .exceptions import HTTPError # Non-authentication HTTP failures.
9
+ from .exceptions import LinkedInError # Base SDK failure.
10
+ from .exceptions import NetworkError # Network failure with no automatic retry.
11
+ from .exceptions import OperationRejected # LinkedIn refused the operation and said why.
12
+ from .exceptions import ProtocolError # Unsupported response shape or redirect.
13
+ from .exceptions import RateLimited # Server or persisted cooldown.
14
+ from .exceptions import SessionError # Invalid persisted state.
15
+ from .exceptions import SessionInUse # Exclusive session ownership conflict.
16
+ from .exceptions import UnconfirmedWrite # A write was accepted but its reply unreadable.
17
+ from .models import ApplicationForm # An Easy Apply form: pages, questions, resumes.
18
+ from .models import ApplicationStatus # Result of a submitted application.
19
+ from .models import Author # Member or company shown on a post or comment.
20
+ from .models import Comment # One comment or reply.
21
+ from .models import CommentScope # Who may comment on a post.
22
+ from .models import CommentSort # Most relevant or most recent comments.
23
+ from .models import Commitment # Employer commitments filter.
24
+ from .models import CompanyContent # Which company page posts to list.
25
+ from .models import CompanyFilters # Company search filters.
26
+ from .models import CompanySize # Company size buckets.
27
+ from .models import ContentType # Post search content types.
28
+ from .models import CourseFilters # Course search filters.
29
+ from .models import CourseLength # Course length buckets.
30
+ from .models import CourseLevel # Course levels.
31
+ from .models import Cursor # Continues the request that produced a page.
32
+ from .models import DatePosted # Post search date ranges.
33
+ from .models import Document # A document attachment.
34
+ from .models import ExperienceLevel # Job seniority filter.
35
+ from .models import FeedSort # Top or recent main feed.
36
+ from .models import FileQuestion # A file slot such as the resume.
37
+ from .models import Filter # One available search filter.
38
+ from .models import FilterOption # One suggested filter value.
39
+ from .models import FormPage # One page of an Easy Apply form.
40
+ from .models import Image # An image attachment.
41
+ from .models import Job # One job posting as a seeker sees it.
42
+ from .models import JobDatePosted # Job posting date ranges.
43
+ from .models import JobFilters # Job search filters.
44
+ from .models import JobResult # One job card in search results.
45
+ from .models import JobSort # Job search order.
46
+ from .models import JobType # Full-time, part-time, contract and more.
47
+ from .models import LinkPreview # A link attachment.
48
+ from .models import MediaKind # Image, video or document upload.
49
+ from .models import MediaStatus # Processing state of an upload.
50
+ from .models import Member # Verified member identity.
51
+ from .models import Mention # A tagged member inside comment text.
52
+ from .models import Network # 1st, 2nd and 3rd+ connections.
53
+ from .models import NewPoll # A poll to publish.
54
+ from .models import Option # One choice of a question.
55
+ from .models import Page # One page of results.
56
+ from .models import PeopleFilters # People search filters.
57
+ from .models import PersonResult # A person in search results.
58
+ from .models import Poll # A poll attached to a post.
59
+ from .models import PollDuration # How long a new poll runs.
60
+ from .models import PollOption # One poll choice and its vote state.
61
+ from .models import Post # One feed update.
62
+ from .models import PostedBy # Post search author scope.
63
+ from .models import PostFilters # Post search filters.
64
+ from .models import PostSort # Post search order.
65
+ from .models import ProductFilters # Product search filters.
66
+ from .models import ProfileActivity # A member's posts, comments or reactions.
67
+ from .models import Question # One Easy Apply question.
68
+ from .models import QuestionKind # Text, number, single choice, dropdown, checkbox.
69
+ from .models import ReactionSort # Order of a reactions list.
70
+ from .models import ReactionType # Like, Celebrate, Support, Love, Insightful, Funny.
71
+ from .models import Reactor # Who reacted to a post, and how.
72
+ from .models import Repost # An instant repost.
73
+ from .models import ResultGroup # One block of blended results.
74
+ from .models import Resume # A resume offered from earlier applications.
75
+ from .models import SavedPost # One entry of the saved-posts list.
76
+ from .models import ScheduledPost # One scheduled post.
77
+ from .models import SearchResult # A company, group, school, event, course or product.
78
+ from .models import ServiceFilters # Service search filters.
79
+ from .models import SharedPost # A published or scheduled post.
80
+ from .models import SocialCounts # Reaction, comment and repost totals.
81
+ from .models import Submission # A checked application body, not yet sent.
82
+ from .models import Suggestion # A typeahead match; its id is a filter value.
83
+ from .models import TrackedJob # One entry of the My jobs lists.
84
+ from .models import TrackedStatus # Saved, in progress, applied or archived.
85
+ from .models import TypeaheadKind # What a typeahead looks up.
86
+ from .models import UploadedFile # A stored file, ready to attach.
87
+ from .models import UploadedMedia # An uploaded file, ready to attach.
88
+ from .models import Vertical # Which kind of result a search returns.
89
+ from .models import Video # A video attachment.
90
+ from .models import Visibility # Anyone or connections only.
91
+ from .models import WorkplaceType # On-site, remote or hybrid.
92
+ from .session import Session # Private cookie state and persistence.
93
+
94
+ __all__ = [
95
+ "LinkedIn",
96
+ "Session",
97
+ "ClientOptions",
98
+ "ClientProfile",
99
+ # Failures
100
+ "AuthenticationError",
101
+ "ChallengeRequired",
102
+ "HTTPError",
103
+ "LinkedInError",
104
+ "NetworkError",
105
+ "OperationRejected",
106
+ "ProtocolError",
107
+ "RateLimited",
108
+ "SessionError",
109
+ "SessionInUse",
110
+ "UnconfirmedWrite",
111
+ # Models
112
+ "ApplicationForm",
113
+ "ApplicationStatus",
114
+ "Author",
115
+ "Comment",
116
+ "CommentScope",
117
+ "CommentSort",
118
+ "Commitment",
119
+ "CompanyContent",
120
+ "CompanyFilters",
121
+ "CompanySize",
122
+ "ContentType",
123
+ "CourseFilters",
124
+ "CourseLength",
125
+ "CourseLevel",
126
+ "Cursor",
127
+ "DatePosted",
128
+ "Document",
129
+ "ExperienceLevel",
130
+ "FeedSort",
131
+ "FileQuestion",
132
+ "Filter",
133
+ "FilterOption",
134
+ "FormPage",
135
+ "Image",
136
+ "Job",
137
+ "JobDatePosted",
138
+ "JobFilters",
139
+ "JobResult",
140
+ "JobSort",
141
+ "JobType",
142
+ "LinkPreview",
143
+ "MediaKind",
144
+ "MediaStatus",
145
+ "Member",
146
+ "Mention",
147
+ "Network",
148
+ "NewPoll",
149
+ "Option",
150
+ "Page",
151
+ "PeopleFilters",
152
+ "PersonResult",
153
+ "Poll",
154
+ "PollDuration",
155
+ "PollOption",
156
+ "Post",
157
+ "PostFilters",
158
+ "PostSort",
159
+ "PostedBy",
160
+ "ProductFilters",
161
+ "ProfileActivity",
162
+ "Question",
163
+ "QuestionKind",
164
+ "ReactionSort",
165
+ "ReactionType",
166
+ "Reactor",
167
+ "Repost",
168
+ "ResultGroup",
169
+ "Resume",
170
+ "SavedPost",
171
+ "ScheduledPost",
172
+ "SearchResult",
173
+ "ServiceFilters",
174
+ "SharedPost",
175
+ "SocialCounts",
176
+ "Submission",
177
+ "Suggestion",
178
+ "TrackedJob",
179
+ "TrackedStatus",
180
+ "TypeaheadKind",
181
+ "UploadedFile",
182
+ "UploadedMedia",
183
+ "Vertical",
184
+ "Video",
185
+ "Visibility",
186
+ "WorkplaceType",
187
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,5 @@
1
+ """Auth public exports."""
2
+
3
+ from .service import Auth # Explicit login orchestration and bootstrap.
4
+
5
+ __all__ = ["Auth"]
@@ -0,0 +1,77 @@
1
+ """The recorded Google ID-token exchange; Google token acquisition is caller-owned."""
2
+
3
+ import asyncio
4
+ import secrets
5
+
6
+ from ..exceptions import AuthenticationError
7
+ from ..exceptions import ChallengeRequired
8
+ from ..exceptions import ProtocolError
9
+ from ..models import Member
10
+ from ..resources import Account
11
+ from ..session.cookies import SessionCookie
12
+ from ..transport import Transport
13
+
14
+
15
+ class Auth:
16
+ """Bootstrap fresh browser cookies and exchange one caller-supplied Google ID token."""
17
+
18
+ def __init__(self, transport: Transport) -> None:
19
+ self._transport = transport
20
+ self._lock = asyncio.Lock()
21
+
22
+ async def prepare(self) -> None:
23
+ """Fetch current server cookies; create the mobile CSRF cookie if none was issued."""
24
+ await self._transport.request("GET", "/mob/tracking")
25
+ state = self._transport.session
26
+ if not state.cookie_value("JSESSIONID"):
27
+ state.cookies.append(
28
+ SessionCookie(
29
+ "JSESSIONID",
30
+ f'"ajax:{secrets.randbelow(9 * 10**18) + 10**18}"',
31
+ ".www.linkedin.com",
32
+ )
33
+ )
34
+ self._transport.checkpoint()
35
+
36
+ async def login_google(self, id_token: str) -> Member:
37
+ """Exchange a fresh token for the LinkedIn Android audience, then verify the member.
38
+
39
+ Tokens are not persisted or automatically retried. Challenges must be completed
40
+ in the official app. This does not obtain a Google token or accept passwords.
41
+ """
42
+ if not isinstance(id_token, str) or not id_token.strip():
43
+ raise ValueError("A fresh Google ID token is required.")
44
+ async with self._lock:
45
+ state = self._transport.session
46
+ if state.authenticated:
47
+ raise AuthenticationError(
48
+ "A session already exists; use account.me() to verify it."
49
+ )
50
+ await self.prepare()
51
+ form = {
52
+ "soogleLoginRequestType": "ONE_TAP_AUTO_LOGIN",
53
+ "googleIdToken": id_token,
54
+ "client_enabled_features": "ANDROID_NATIVE_CAPTCHA",
55
+ }
56
+ for name in ("JSESSIONID", "bcookie", "bscookie", "lang", "lidc"):
57
+ value = state.cookie_value(name)
58
+ if value is not None:
59
+ form[name] = value.strip('"')
60
+ try:
61
+ response = await self._transport.request("POST", "/uas/authenticate", form)
62
+ result = self._transport.json_object(response)
63
+ challenge = result.get("challenge_url")
64
+ if isinstance(challenge, str) and challenge:
65
+ raise ChallengeRequired(challenge)
66
+ if result.get("login_result") != "PASS":
67
+ raise AuthenticationError("LinkedIn did not accept the Google sign-in.")
68
+ if not state.authenticated:
69
+ raise ProtocolError("Login succeeded without the required session cookies.")
70
+ except BaseException:
71
+ state.clear_authentication()
72
+ self._transport.checkpoint()
73
+ raise
74
+ finally:
75
+ form.clear()
76
+ # A temporary verification failure must not discard a successfully issued session.
77
+ return await Account(self._transport).me()
@@ -0,0 +1,37 @@
1
+ """Interactive credentials; never put tokens in command arguments or shell history."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ from getpass import getpass
6
+ from pathlib import Path
7
+
8
+ from .client import LinkedIn
9
+ from .exceptions import LinkedInError
10
+ from .session import Session
11
+
12
+
13
+ async def run(command: str, path: Path) -> None:
14
+ async with LinkedIn.from_session_file(path) as client:
15
+ if command == "import-session":
16
+ if client.session.cookies:
17
+ raise LinkedInError("Session file already contains cookies; choose a new file.")
18
+ imported = Session.from_cookies(getpass("li_at: "), getpass("JSESSIONID: "))
19
+ client.session.cookies = imported.cookies
20
+ member = await client.account.me()
21
+ elif command == "login-google" and not client.session.authenticated:
22
+ member = await client.auth.login_google(getpass("Fresh Google ID token: "))
23
+ else:
24
+ member = await client.account.me()
25
+ print(f"Verified {member.full_name} (member {member.id}).")
26
+ print(f"Session saved to {path}.")
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(description=__doc__)
31
+ parser.add_argument("command", choices=("import-session", "login-google", "me"))
32
+ parser.add_argument("--session", type=Path, required=True)
33
+ args = parser.parse_args()
34
+ try:
35
+ asyncio.run(run(args.command, args.session.expanduser()))
36
+ except (LinkedInError, OSError, ValueError) as error:
37
+ parser.exit(1, f"{error}\n")
@@ -0,0 +1,93 @@
1
+ """Public client lifecycle and exclusive session-file ownership."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from types import TracebackType
6
+ from typing import Self
7
+
8
+ import httpx
9
+
10
+ from .auth import Auth
11
+ from .config import ClientOptions
12
+ from .resources import Account
13
+ from .resources import Comments
14
+ from .resources import Feed
15
+ from .resources import Jobs
16
+ from .resources import Posts
17
+ from .resources import Search
18
+ from .session import Session
19
+ from .session.lease import SessionLease
20
+ from .transport import Transport
21
+ from .transport import Voyager
22
+
23
+
24
+ class LinkedIn:
25
+ """One account per async context; closing saves state without logging out."""
26
+
27
+ def __init__(
28
+ self,
29
+ session: Session | None = None,
30
+ options: ClientOptions | None = None,
31
+ transport: httpx.AsyncBaseTransport | None = None,
32
+ ) -> None:
33
+ self.session = session if session is not None else Session()
34
+ self._transport = Transport(self.session, options or ClientOptions(), transport)
35
+ self.auth = Auth(self._transport)
36
+ self.account = Account(self._transport)
37
+ voyager = Voyager(self._transport)
38
+ self.feed = Feed(voyager)
39
+ self.posts = Posts(voyager)
40
+ self.comments = Comments(voyager)
41
+ self.search = Search(voyager)
42
+ self.jobs = Jobs(voyager)
43
+ self._lease: SessionLease | None = None
44
+ self._closed = False
45
+ self._close_lock = asyncio.Lock()
46
+
47
+ @classmethod
48
+ def from_session_file(
49
+ cls,
50
+ path: str | Path,
51
+ options: ClientOptions | None = None,
52
+ transport: httpx.AsyncBaseTransport | None = None,
53
+ ) -> Self:
54
+ destination = Path(path).expanduser().resolve()
55
+ lease = SessionLease(destination)
56
+ try:
57
+ session = Session.load(destination) if destination.exists() else Session()
58
+ if not destination.exists():
59
+ session.save(destination)
60
+ client = cls(session, options, transport)
61
+ client._lease = lease
62
+ client._transport.session_file = destination
63
+ return client
64
+ except BaseException:
65
+ lease.close()
66
+ raise
67
+
68
+ async def __aenter__(self) -> Self:
69
+ if self._closed:
70
+ raise RuntimeError("LinkedIn client is closed.")
71
+ return self
72
+
73
+ async def __aexit__(
74
+ self,
75
+ exc_type: type[BaseException] | None,
76
+ exc: BaseException | None,
77
+ traceback: TracebackType | None,
78
+ ) -> None:
79
+ await self.aclose()
80
+
81
+ async def aclose(self) -> None:
82
+ async with self._close_lock:
83
+ if self._closed:
84
+ return
85
+ try:
86
+ await self._transport.aclose()
87
+ finally:
88
+ try:
89
+ self._transport.checkpoint()
90
+ finally:
91
+ self._closed = True
92
+ if self._lease is not None:
93
+ self._lease.close()
@@ -0,0 +1,6 @@
1
+ """Config public exports."""
2
+
3
+ from .options import ClientOptions # HTTP connection settings.
4
+ from .profile import ClientProfile # Versioned app metadata.
5
+
6
+ __all__ = ["ClientOptions", "ClientProfile"]
@@ -0,0 +1,15 @@
1
+ """Caller-controlled connection settings."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ClientOptions:
8
+ """Connection settings; one client owns one connection pool."""
9
+
10
+ timeout: float = 30.0
11
+ max_connections: int = 1
12
+
13
+ def __post_init__(self) -> None:
14
+ if self.timeout <= 0 or self.max_connections < 1:
15
+ raise ValueError("Timeout and connection count must be positive.")
@@ -0,0 +1,56 @@
1
+ """Versioned Android client metadata observed in the local login capture."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class ClientProfile:
9
+ """App metadata, separate from per-account cookies and device identity."""
10
+
11
+ app_version: str = "4.1.1267"
12
+ version_code: int = 217500
13
+ locale: str = "en_US"
14
+ timezone: str = "Asia/Kolkata"
15
+ timezone_offset: float = 5.5
16
+
17
+ @property
18
+ def user_agent(self) -> str:
19
+ return (
20
+ f"com.linkedin.android/{self.version_code} (Linux; U; Android 13; {self.locale}; "
21
+ "sdk_gphone64_arm64; Build/TE1A.240213.009; Cronet/151.0.7922.47)"
22
+ )
23
+
24
+ def auth_headers(self, device_id: str) -> dict[str, str]:
25
+ track = {
26
+ "osName": "Android OS",
27
+ "osVersion": "33",
28
+ "clientVersion": self.app_version,
29
+ "clientMinorVersion": self.version_code,
30
+ "model": "Google_sdk_gphone64_arm64",
31
+ "displayDensity": 2.625,
32
+ "displayWidth": 1080,
33
+ "displayHeight": 2209,
34
+ "dpi": "xhdpi",
35
+ "deviceType": "android",
36
+ "appId": "com.linkedin.android",
37
+ "deviceId": device_id,
38
+ "timezoneOffset": self.timezone_offset,
39
+ "timezone": self.timezone,
40
+ "storeId": "us_googleplay",
41
+ "isAdTrackingLimited": False,
42
+ "mpName": "voyager-android",
43
+ "mpVersion": "2.344.49",
44
+ }
45
+ return {
46
+ "user-agent": "ANDROID OS",
47
+ "x-udid": device_id,
48
+ "x-restli-protocol-version": "2.0.0",
49
+ "accept-language": self.locale.replace("_", "-"),
50
+ "x-li-lang": self.locale,
51
+ "x-li-track": json.dumps(track, separators=(",", ":")),
52
+ "x-li-user-agent": (
53
+ f"LIAuthLibrary:0.0.3 com.linkedin.android:{self.app_version} "
54
+ "Google_sdk_gphone64_arm64:android_13"
55
+ ),
56
+ }
@@ -0,0 +1,75 @@
1
+ """Public failures with messages that never include credentials or response bodies."""
2
+
3
+
4
+ class LinkedInError(Exception):
5
+ """Base class for expected client failures."""
6
+
7
+
8
+ class SessionError(LinkedInError):
9
+ """The session file is invalid or cannot be used."""
10
+
11
+
12
+ class SessionInUse(SessionError):
13
+ """Another client owns this session file."""
14
+
15
+
16
+ class AuthenticationError(LinkedInError):
17
+ """Credentials were rejected or the session expired."""
18
+
19
+
20
+ class ChallengeRequired(AuthenticationError):
21
+ """Complete verification in the official app; no automatic continuation occurs."""
22
+
23
+ def __init__(self, challenge_url: str | None = None) -> None:
24
+ super().__init__("LinkedIn requires verification in the official app.")
25
+ # The URL can contain private state, so it is never part of the message or repr.
26
+ self.challenge_url = challenge_url
27
+
28
+
29
+ class RateLimited(LinkedInError):
30
+ """No retry occurs; retry_after is the remaining cooldown in seconds."""
31
+
32
+ def __init__(self, retry_after: float) -> None:
33
+ super().__init__("LinkedIn rate limit reached; wait before another request.")
34
+ self.retry_after = retry_after
35
+
36
+
37
+ class ProtocolError(LinkedInError):
38
+ """The response does not match the supported protocol."""
39
+
40
+
41
+ class UnconfirmedWrite(ProtocolError):
42
+ """LinkedIn accepted a write, but its reply could not be read.
43
+
44
+ The change may have been applied. Read the state back before sending it again; the
45
+ client never retries a write.
46
+ """
47
+
48
+
49
+ class HTTPError(LinkedInError):
50
+ """An HTTP failure, without attaching the credential-bearing HTTPX response.
51
+
52
+ `reason` is rest.li's one-line client-error message when the reply carried one.
53
+ """
54
+
55
+ def __init__(self, status_code: int, reason: str | None = None) -> None:
56
+ detail = f": {reason}" if reason else "."
57
+ super().__init__(f"LinkedIn returned HTTP {status_code}{detail}")
58
+ self.status_code = status_code
59
+ self.reason = reason
60
+
61
+
62
+ class OperationRejected(LinkedInError):
63
+ """LinkedIn answered but refused the operation, for example a GraphQL validation error.
64
+
65
+ `reasons` holds the server's messages; nothing was retried.
66
+ """
67
+
68
+ def __init__(self, reasons: tuple[str, ...]) -> None:
69
+ summary = reasons[0] if reasons else "no reason given"
70
+ super().__init__(f"LinkedIn rejected the operation: {summary}")
71
+ self.reasons = reasons
72
+
73
+
74
+ class NetworkError(LinkedInError):
75
+ """The exchange failed; its outcome may be unknown and it was not retried."""