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,129 @@
1
+ """Scoped cookies and atomic, private session persistence."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import time
7
+ from dataclasses import asdict
8
+ from dataclasses import dataclass
9
+ from dataclasses import field
10
+ from pathlib import Path
11
+ from uuid import uuid4
12
+
13
+ import httpx
14
+
15
+ from ..config import ClientProfile
16
+ from ..exceptions import SessionError
17
+ from .cookies import SessionCookie
18
+
19
+
20
+ @dataclass(slots=True, repr=False)
21
+ class Session:
22
+ """Local credential presence is not proof that LinkedIn still accepts the session."""
23
+
24
+ device_id: str = field(default_factory=lambda: str(uuid4()))
25
+ profile: ClientProfile = field(default_factory=ClientProfile)
26
+ cookies: list[SessionCookie] = field(default_factory=list)
27
+ member_id: str | None = None
28
+ rate_limit_until: float = 0.0
29
+
30
+ def __repr__(self) -> str:
31
+ return f"Session(authenticated={self.authenticated})"
32
+
33
+ @property
34
+ def authenticated(self) -> bool:
35
+ return bool(self.cookie_value("li_at") and self.cookie_value("JSESSIONID"))
36
+
37
+ def cookie_value(self, name: str, path: str = "/") -> str | None:
38
+ valid = [
39
+ cookie
40
+ for cookie in self.cookies
41
+ if cookie.name == name
42
+ and (cookie.expires is None or cookie.expires > time.time())
43
+ and (path == cookie.path or path.startswith(cookie.path.rstrip("/") + "/"))
44
+ and (not cookie.host_only or cookie.domain == "www.linkedin.com")
45
+ ]
46
+ if not valid:
47
+ return None
48
+ # Prefer the most specific matching cookie, including a host-specific cookie.
49
+ cookie = max(valid, key=lambda c: (len(c.path), len(c.domain.lstrip("."))))
50
+ return cookie.value
51
+
52
+ def clear_authentication(self) -> None:
53
+ self.cookies = [c for c in self.cookies if c.name != "li_at"]
54
+ self.member_id = None
55
+
56
+ def to_httpx(self) -> httpx.Cookies:
57
+ jar = httpx.Cookies()
58
+ for cookie in self.cookies:
59
+ jar.jar.set_cookie(cookie.to_cookie())
60
+ return jar
61
+
62
+ def update_cookies(self, jar: httpx.Cookies) -> None:
63
+ self.cookies = [
64
+ SessionCookie(
65
+ name=c.name,
66
+ value=c.value or "",
67
+ domain=c.domain,
68
+ path=c.path,
69
+ secure=c.secure,
70
+ expires=c.expires,
71
+ host_only=not c.domain_specified,
72
+ )
73
+ for c in jar.jar
74
+ if c.domain.lstrip(".") in {"linkedin.com", "www.linkedin.com"}
75
+ ]
76
+
77
+ @classmethod
78
+ def from_cookies(cls, li_at: str, jsessionid: str) -> "Session":
79
+ """Import your own session cookie pair; accepts JSESSIONID with or without quotes."""
80
+ if not li_at.strip() or not jsessionid.strip().strip('"'):
81
+ raise SessionError("Both li_at and JSESSIONID are required.")
82
+ csrf = jsessionid.strip().strip('"')
83
+ return cls(
84
+ cookies=[
85
+ SessionCookie("li_at", li_at.strip(), ".www.linkedin.com"),
86
+ SessionCookie("JSESSIONID", f'"{csrf}"', ".www.linkedin.com"),
87
+ ]
88
+ )
89
+
90
+ def save(self, path: str | Path) -> None:
91
+ """Atomically write mode 0600; credentials are never printed or put in errors."""
92
+ destination = Path(path).expanduser()
93
+ destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
94
+ temporary = None
95
+ try:
96
+ with tempfile.NamedTemporaryFile(
97
+ mode="w", encoding="utf-8", dir=destination.parent, delete=False
98
+ ) as stream:
99
+ temporary = stream.name
100
+ os.fchmod(stream.fileno(), 0o600)
101
+ json.dump({"schema_version": 1, **asdict(self)}, stream, indent=2)
102
+ stream.flush()
103
+ os.fsync(stream.fileno())
104
+ os.replace(temporary, destination)
105
+ finally:
106
+ if temporary and os.path.exists(temporary):
107
+ os.unlink(temporary)
108
+
109
+ @classmethod
110
+ def load(cls, path: str | Path) -> "Session":
111
+ """Load supported state without silently replacing corrupt files or expired tokens."""
112
+ try:
113
+ data = json.loads(Path(path).expanduser().read_text())
114
+ if data.pop("schema_version") != 1:
115
+ raise ValueError
116
+ profile = ClientProfile(**data.pop("profile"))
117
+ cookies = [SessionCookie(**cookie) for cookie in data.pop("cookies")]
118
+ state = cls(profile=profile, cookies=cookies, **data)
119
+ if (
120
+ not isinstance(state.device_id, str)
121
+ or not state.device_id
122
+ or any(ch in state.device_id for ch in "\r\n")
123
+ or not isinstance(state.rate_limit_until, (float, int))
124
+ or (state.member_id is not None and not isinstance(state.member_id, str))
125
+ ):
126
+ raise ValueError
127
+ return state
128
+ except (ValueError, TypeError, KeyError, AttributeError, SessionError):
129
+ raise SessionError("Invalid or unsupported LinkedIn session file.") from None
@@ -0,0 +1,6 @@
1
+ """Transport public exports."""
2
+
3
+ from .http import Transport # Shared HTTP exchange and cookie rotation.
4
+ from .voyager import Voyager # Voyager GraphQL and rest.li calls over the transport.
5
+
6
+ __all__ = ["Transport", "Voyager"]
@@ -0,0 +1,183 @@
1
+ """Origin-bound HTTP exchanges with cookie rotation, cooldowns, and no automatic retries."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Mapping
6
+ from datetime import UTC
7
+ from datetime import datetime
8
+ from email.utils import parsedate_to_datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import unquote
12
+ from urllib.parse import urlsplit
13
+
14
+ import httpx
15
+
16
+ from ..config import ClientOptions
17
+ from ..exceptions import AuthenticationError
18
+ from ..exceptions import ChallengeRequired
19
+ from ..exceptions import HTTPError
20
+ from ..exceptions import NetworkError
21
+ from ..exceptions import ProtocolError
22
+ from ..exceptions import RateLimited
23
+ from ..session import Session
24
+
25
+ ORIGIN = "https://www.linkedin.com"
26
+
27
+
28
+ class Transport:
29
+ """Own the connection pool and serialize updates to one account's cookie jar."""
30
+
31
+ def __init__(
32
+ self,
33
+ session: Session,
34
+ options: ClientOptions,
35
+ transport: httpx.AsyncBaseTransport | None = None,
36
+ ) -> None:
37
+ self.session = session
38
+ self.session_file: Path | None = None
39
+ self._lock = asyncio.Lock()
40
+ self._closed = False
41
+ self._client = httpx.AsyncClient(
42
+ timeout=options.timeout,
43
+ http2=True,
44
+ trust_env=False,
45
+ follow_redirects=False,
46
+ limits=httpx.Limits(max_connections=options.max_connections),
47
+ transport=transport,
48
+ )
49
+
50
+ async def request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ data: Mapping[str, str] | None = None,
55
+ *,
56
+ json: object | None = None,
57
+ content: bytes | None = None,
58
+ headers: Mapping[str, str] | None = None,
59
+ ) -> httpx.Response:
60
+ """Send one exchange.
61
+
62
+ `json` bodies and extra headers serve the Voyager API; `content` sends raw upload
63
+ bytes (set its content-type in `headers`).
64
+ """
65
+ if not path.startswith("/") or path.startswith("//") or "#" in path:
66
+ raise ValueError("A LinkedIn origin-relative path is required.")
67
+ if sum(body is not None for body in (data, json, content)) > 1:
68
+ raise ValueError("Send one body: form data, JSON or raw content.")
69
+ async with self._lock:
70
+ if self._closed:
71
+ raise RuntimeError("LinkedIn client is closed.")
72
+ remaining = self.session.rate_limit_until - time.time()
73
+ if remaining > 0:
74
+ raise RateLimited(remaining)
75
+ sent = self.session.profile.auth_headers(self.session.device_id)
76
+ csrf = self.session.cookie_value("JSESSIONID", path)
77
+ if csrf:
78
+ sent["csrf-token"] = csrf.strip('"')
79
+ if path == "/mob/tracking":
80
+ sent = {"user-agent": self.session.profile.user_agent}
81
+ sent.update(headers or {})
82
+ self._client.cookies = self.session.to_httpx()
83
+ try:
84
+ response = await self._client.request(
85
+ method,
86
+ ORIGIN + path,
87
+ headers=sent,
88
+ data=data,
89
+ json=json,
90
+ content=content,
91
+ )
92
+ except httpx.RequestError:
93
+ raise NetworkError("LinkedIn request failed and was not retried.") from None
94
+ finally:
95
+ self.session.update_cookies(self._client.cookies)
96
+ self.checkpoint()
97
+ if response.status_code == 429:
98
+ delay = self._retry_after(response.headers.get("retry-after"))
99
+ self.session.rate_limit_until = time.time() + delay
100
+ self.checkpoint()
101
+ raise RateLimited(delay)
102
+ if response.status_code == 401:
103
+ self.session.clear_authentication()
104
+ self.checkpoint()
105
+ raise AuthenticationError("LinkedIn rejected the session or credentials.")
106
+ if response.status_code == 403:
107
+ raise ChallengeRequired()
108
+ if 300 <= response.status_code < 400:
109
+ raise ProtocolError("LinkedIn returned a redirect; it was not followed.")
110
+ if response.status_code >= 400:
111
+ raise HTTPError(response.status_code, self._service_message(response))
112
+ return response
113
+
114
+ @staticmethod
115
+ def upload_path(url: object, prefix: str) -> str | None:
116
+ """The origin path of an upload URL LinkedIn handed out, or None if it is not ours.
117
+
118
+ Only https://www.linkedin.com URLs under `prefix` qualify; dot segments (also
119
+ percent-encoded) are refused because the HTTP client would resolve them elsewhere.
120
+ """
121
+ if not isinstance(url, str):
122
+ return None
123
+ parts = urlsplit(url)
124
+ segments = unquote(parts.path).split("/")
125
+ if (
126
+ parts.scheme != "https"
127
+ or parts.netloc != "www.linkedin.com"
128
+ or not parts.path.startswith(prefix)
129
+ or "." in segments
130
+ or ".." in segments
131
+ or "\\" in parts.path
132
+ ):
133
+ return None
134
+ return parts.path + (f"?{parts.query}" if parts.query else "")
135
+
136
+ def checkpoint(self) -> None:
137
+ if self.session_file is not None:
138
+ self.session.save(self.session_file)
139
+
140
+ @staticmethod
141
+ def json_object(response: httpx.Response) -> dict[str, Any]:
142
+ try:
143
+ data = response.json()
144
+ except ValueError:
145
+ raise ProtocolError("LinkedIn returned an unsupported response format.") from None
146
+ if not isinstance(data, dict):
147
+ raise ProtocolError("LinkedIn returned an unsupported response shape.")
148
+ return data
149
+
150
+ @staticmethod
151
+ def _service_message(response: httpx.Response) -> str | None:
152
+ """Rest.li's short client-error reason; server traces and bodies are never kept."""
153
+ if not 400 <= response.status_code < 500:
154
+ return None
155
+ try:
156
+ data = response.json()
157
+ except ValueError:
158
+ return None
159
+ message = data.get("message") if isinstance(data, dict) else None
160
+ if isinstance(message, str) and message and "\n" not in message:
161
+ return message[:300]
162
+ return None
163
+
164
+ @staticmethod
165
+ def _retry_after(value: str | None) -> float:
166
+ if value:
167
+ try:
168
+ return max(0.0, float(int(value)))
169
+ except ValueError:
170
+ try:
171
+ date = parsedate_to_datetime(value)
172
+ if date.tzinfo is None:
173
+ date = date.replace(tzinfo=UTC)
174
+ return max(0.0, (date - datetime.now(UTC)).total_seconds())
175
+ except (ValueError, TypeError, OverflowError):
176
+ pass
177
+ return 60.0
178
+
179
+ async def aclose(self) -> None:
180
+ async with self._lock:
181
+ if not self._closed:
182
+ self._closed = True
183
+ await self._client.aclose()
@@ -0,0 +1,46 @@
1
+ """Rest.li 2.0 URL encoding, as the app's DataEncoder writes GraphQL variables."""
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from collections.abc import Sequence
6
+ from decimal import Decimal
7
+ from urllib.parse import quote
8
+
9
+ # Rest.li reserves these characters inside values; they and '%' become %XX first.
10
+ _RESERVED = frozenset("(:),'%")
11
+ # Android's Uri.encode keeps these (plus '%', so the rest.li escapes survive).
12
+ _URI_SAFE = "-_.!~*'()%"
13
+
14
+
15
+ def encode(value: object) -> str:
16
+ """Encode records as `(k:v)`, lists as `List(a,b)`, strings escaped and percent-encoded."""
17
+ if isinstance(value, bool):
18
+ return "true" if value else "false"
19
+ if isinstance(value, int):
20
+ return str(value)
21
+ if isinstance(value, float):
22
+ if value != value or value in (float("inf"), float("-inf")):
23
+ raise ValueError("Rest.li values must be finite numbers.")
24
+ # Plain decimal notation: an exponent's '+' would read as a space in a URL.
25
+ return format(Decimal(repr(value)), "f")
26
+ if isinstance(value, str):
27
+ return _string(value)
28
+ if isinstance(value, Mapping):
29
+ parts = []
30
+ for key, item in value.items():
31
+ # Keys are written verbatim, as the app does, so they must be plain identifiers.
32
+ if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_$]*", key):
33
+ raise ValueError("Rest.li record keys must be identifiers.")
34
+ if item is not None:
35
+ parts.append(f"{key}:{encode(item)}")
36
+ return "(" + ",".join(parts) + ")"
37
+ if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
38
+ return "List(" + ",".join(encode(item) for item in value) + ")"
39
+ raise ValueError(f"Rest.li cannot encode {type(value).__name__} values.")
40
+
41
+
42
+ def _string(value: str) -> str:
43
+ if not value:
44
+ return "''"
45
+ escaped = "".join(f"%{ord(ch):02X}" if ch in _RESERVED else ch for ch in value)
46
+ return quote(escaped, safe=_URI_SAFE)
@@ -0,0 +1,151 @@
1
+ """The Voyager JSON API: persisted GraphQL operations and rest.li resources.
2
+
3
+ The Android app ships these operations and asks for protobuf; the same routes answer plain
4
+ JSON when asked, so requests carry `accept: application/json` and JSON bodies.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from ..exceptions import OperationRejected
13
+ from ..exceptions import ProtocolError
14
+ from . import restli
15
+ from .http import Transport
16
+
17
+ _JSON = {"accept": "application/json"}
18
+ _GRAPHQL = {"accept": "application/json", "x-li-graphql-pegasus-client": "true"}
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class GraphQLQuery:
23
+ """One persisted GraphQL operation from the app: query ID plus operation name."""
24
+
25
+ id: str
26
+ name: str
27
+
28
+ def __post_init__(self) -> None:
29
+ if not re.fullmatch(r"voyager[A-Za-z]+\.[0-9a-f]{32}", self.id):
30
+ raise ValueError("GraphQL query IDs look like voyagerName.<32 hex digits>.")
31
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", self.name):
32
+ raise ValueError("GraphQL operation names are identifiers.")
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Reply:
37
+ """A rest.li reply: its JSON object (empty when none) and the created entity's ID."""
38
+
39
+ data: dict[str, Any]
40
+ created_id: str | None = None
41
+
42
+
43
+ class Voyager:
44
+ """Voyager calls over the client's one transport; no retries, no hidden paging."""
45
+
46
+ def __init__(self, transport: Transport) -> None:
47
+ self.transport = transport
48
+
49
+ @property
50
+ def owner(self) -> str:
51
+ """The session's device ID, which ties page cursors to this session."""
52
+ return self.transport.session.device_id
53
+
54
+ async def query(
55
+ self,
56
+ query: GraphQLQuery,
57
+ variables: Mapping[str, object] | None = None,
58
+ *,
59
+ ignore_null_field_errors: bool = False,
60
+ ) -> dict[str, Any]:
61
+ """Run a read and return its `data` object.
62
+
63
+ Some finders report a null in a field they declare non-null next to complete data.
64
+ `ignore_null_field_errors` accepts those replies; any other error still raises.
65
+ """
66
+ params = f"variables={restli.encode(variables)}&" if variables else ""
67
+ path = f"/voyager/api/graphql?{params}queryId={query.id}&queryName={query.name}"
68
+ response = await self.transport.request("GET", path, headers=_GRAPHQL)
69
+ envelope = self.transport.json_object(response)
70
+ if ignore_null_field_errors and isinstance(envelope.get("data"), dict):
71
+ errors = envelope.get("errors")
72
+ if isinstance(errors, list) and all(map(_null_field_error, errors)):
73
+ envelope = {"data": envelope["data"]}
74
+ return _graphql_data(envelope)
75
+
76
+ async def mutate(self, query: GraphQLQuery, variables: Mapping[str, object]) -> dict[str, Any]:
77
+ """Run a write once and return its `data` object; a timeout leaves the outcome unknown."""
78
+ path = f"/voyager/api/graphql?action=execute&queryId={query.id}&queryName={query.name}"
79
+ body = {"queryId": query.id, "queryName": query.name, "variables": dict(variables)}
80
+ response = await self.transport.request("POST", path, json=body, headers=_GRAPHQL)
81
+ envelope = self.transport.json_object(response)
82
+ value = envelope.get("value")
83
+ if not isinstance(value, dict):
84
+ raise ProtocolError("GraphQL action reply had no value object.")
85
+ return _graphql_data(value)
86
+
87
+ async def get(
88
+ self, resource: str, params: Mapping[str, object] | None = None
89
+ ) -> dict[str, Any]:
90
+ """Read a rest.li resource such as `voyagerJobsDashJobPostings/<id>`."""
91
+ response = await self.transport.request(
92
+ "GET", _resource_path(resource, params), headers=_JSON
93
+ )
94
+ return self.transport.json_object(response)
95
+
96
+ async def post(
97
+ self,
98
+ resource: str,
99
+ body: Mapping[str, object],
100
+ params: Mapping[str, object] | None = None,
101
+ ) -> Reply:
102
+ """Create or run a rest.li action once; the server may reply with no body."""
103
+ response = await self.transport.request(
104
+ "POST", _resource_path(resource, params), json=dict(body), headers=_JSON
105
+ )
106
+ data = self.transport.json_object(response) if response.content else {}
107
+ created = response.headers.get("x-restli-id") or response.headers.get("x-linkedin-id")
108
+ return Reply(data, created)
109
+
110
+ async def delete(self, resource: str, params: Mapping[str, object] | None = None) -> None:
111
+ """Delete a rest.li entity once."""
112
+ await self.transport.request("DELETE", _resource_path(resource, params), headers=_JSON)
113
+
114
+
115
+ def _resource_path(resource: str, params: Mapping[str, object] | None) -> str:
116
+ if not re.fullmatch(r"voyager[A-Za-z]+(/[A-Za-z0-9%()_:.,'\-~!*]+)*", resource):
117
+ raise ValueError("A Voyager resource name such as voyagerJobsDashJobPostings is required.")
118
+ path = "/voyager/api/" + resource
119
+ if params:
120
+ query = "&".join(
121
+ f"{key}={value if isinstance(value, str) and key in _RAW else restli.encode(value)}"
122
+ for key, value in params.items()
123
+ if value is not None
124
+ )
125
+ path += "?" + query
126
+ return path
127
+
128
+
129
+ # Plain query parameters the app writes without rest.li value encoding.
130
+ _RAW = frozenset({"q", "action", "decorationId"})
131
+
132
+
133
+ def _null_field_error(error: object) -> bool:
134
+ if not isinstance(error, dict) or not isinstance(error.get("extensions"), dict):
135
+ return False
136
+ return bool(error["extensions"].get("classification") == "NullValueInNonNullableField")
137
+
138
+
139
+ def _graphql_data(envelope: dict[str, Any]) -> dict[str, Any]:
140
+ errors = envelope.get("errors")
141
+ if errors:
142
+ reasons = tuple(
143
+ str(error.get("message"))[:300]
144
+ for error in errors
145
+ if isinstance(error, dict) and error.get("message")
146
+ )
147
+ raise OperationRejected(reasons)
148
+ data = envelope.get("data")
149
+ if not isinstance(data, dict):
150
+ raise ProtocolError("GraphQL reply had no data object.")
151
+ return data