linear-python-client 0.1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HackerOne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.4
2
+ Name: linear-python-client
3
+ Version: 0.1.0
4
+ Summary: Pragmatic Python client for the Linear GraphQL API
5
+ Keywords: linear,linear.app,graphql,api,client,sdk
6
+ Author: eli-the-wizard
7
+ Author-email: eli-the-wizard <ewinter@hackerone.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: jupyter>=1.1.1
19
+ Requires-Dist: pydantic>=2.7
20
+ Requires-Python: >=3.14
21
+ Project-URL: Homepage, https://github.com/Hacker0x01/linear-python
22
+ Project-URL: Documentation, https://hacker0x01.github.io/linear-python/
23
+ Project-URL: Repository, https://github.com/Hacker0x01/linear-python
24
+ Project-URL: Issues, https://github.com/Hacker0x01/linear-python/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # linear-python-client
28
+
29
+ A small, pragmatic synchronous Python client for the [Linear](https://linear.app)
30
+ GraphQL API. Linear's official SDK is TypeScript-only — this package gives Python
31
+ the same ergonomics, built on [Pydantic](https://docs.pydantic.dev/): every call
32
+ takes a typed **`*Request`** model and returns a dedicated **`*Response`** model, so
33
+ inputs and outputs are explicit and validated. A generic `execute()` escape hatch
34
+ covers anything the typed methods don't.
35
+
36
+ Built against the [Linear developer docs](https://linear.app/developers).
37
+
38
+ 📖 **Full documentation:** <https://hacker0x01.github.io/linear-python/>
39
+
40
+ ## Installation
41
+
42
+ ```sh
43
+ uv add linear-python-client
44
+ # or
45
+ pip install linear-python-client
46
+ ```
47
+
48
+ Or for local development of this repo:
49
+
50
+ ```sh
51
+ uv sync
52
+ ```
53
+
54
+ Requires Python 3.14+.
55
+
56
+ ## Authentication
57
+
58
+ The client accepts either a personal API key or an OAuth 2.0 access token.
59
+
60
+ ```python
61
+ from linear_python_client import LinearClient
62
+
63
+ # Personal API key (sent as the raw `Authorization` header value)
64
+ client = LinearClient(api_key="lin_api_...")
65
+
66
+ # OAuth 2.0 access token (sent as `Authorization: Bearer <token>`)
67
+ client = LinearClient(access_token="...")
68
+
69
+ # Or set LINEAR_API_KEY in the environment and call LinearClient()
70
+ client = LinearClient()
71
+ ```
72
+
73
+ Use it as a context manager to close the underlying HTTP client automatically:
74
+
75
+ ```python
76
+ with LinearClient() as client:
77
+ print(client.viewer().viewer.name)
78
+ ```
79
+
80
+ ## Quickstart
81
+
82
+ Each method takes a `*Request` and returns a `*Response`:
83
+
84
+ ```python
85
+ from linear_python_client import (
86
+ LinearClient,
87
+ IssueRequest,
88
+ IssueCreateRequest,
89
+ IssueUpdateRequest,
90
+ IssueArchiveRequest,
91
+ CommentCreateRequest,
92
+ )
93
+
94
+ with LinearClient() as client:
95
+ # The authenticated user
96
+ me = client.viewer().viewer
97
+ print(me.name, me.email)
98
+
99
+ # Fetch a single issue by id or identifier
100
+ issue = client.issue(IssueRequest(id="ENG-123")).issue
101
+ print(issue.title, issue.state.name)
102
+
103
+ # Create an issue
104
+ created = client.create_issue(
105
+ IssueCreateRequest(
106
+ team_id="9cfb482a-81e3-4154-b5b9-2c805e70a02d",
107
+ title="New exception",
108
+ description="More detailed error report in **markdown**",
109
+ priority=2,
110
+ )
111
+ )
112
+ print(created.success, created.issue.identifier)
113
+
114
+ # Update it
115
+ client.update_issue(IssueUpdateRequest(id=created.issue.id, title="Renamed", priority=1))
116
+
117
+ # Comment on it
118
+ client.create_comment(CommentCreateRequest(issue_id=created.issue.id, body="On it 👍"))
119
+
120
+ # Archive it
121
+ client.archive_issue(IssueArchiveRequest(id=created.issue.id))
122
+ ```
123
+
124
+ Field names are Pythonic snake_case with camelCase aliases, so `IssueCreateRequest`
125
+ accepts `team_id=` (or `teamId=`) and the parsed models expose `issue.created_at`,
126
+ `issue.assignee.display_name`, and so on.
127
+
128
+ ## Listing, filtering & pagination
129
+
130
+ List methods take a `*Request` (with `first`, `after`, and a `filter` dict that maps
131
+ directly to Linear's [filtering syntax](https://linear.app/developers/filtering)) and
132
+ return a `*Response` that holds `.nodes` and `.page_info` (and is iterable).
133
+
134
+ ```python
135
+ from linear_python_client import IssuesRequest
136
+
137
+ # First 20 high-priority issues assigned to a specific user
138
+ resp = client.issues(
139
+ IssuesRequest(
140
+ first=20,
141
+ filter={
142
+ "priority": {"eq": 1},
143
+ "assignee": {"email": {"eq": "you@example.com"}},
144
+ },
145
+ order_by="updatedAt",
146
+ )
147
+ )
148
+ for issue in resp.nodes:
149
+ print(issue.identifier, issue.title)
150
+
151
+ print(resp.page_info.has_next_page, resp.page_info.end_cursor)
152
+ ```
153
+
154
+ Use `paginate()` to transparently follow the cursor across every page. Pass the list
155
+ method and a starting request:
156
+
157
+ ```python
158
+ for issue in client.paginate(client.issues, IssuesRequest(filter={"state": {"type": {"eq": "started"}}})):
159
+ print(issue.identifier, issue.title)
160
+ ```
161
+
162
+ `paginate()` works with any list method (`client.issues`, `client.teams`,
163
+ `client.projects`, `client.comments`, `client.users`, …) and its matching request.
164
+
165
+ ## Labels, status & full details
166
+
167
+ ```python
168
+ from linear_python_client import (
169
+ IssueAddLabelRequest,
170
+ IssueRemoveLabelRequest,
171
+ IssueSetStateRequest,
172
+ FindWorkflowStateRequest,
173
+ IssueRequest,
174
+ )
175
+
176
+ # Add / remove a single label without disturbing the issue's other labels
177
+ client.add_label(IssueAddLabelRequest(id=issue_id, label_id=label_id))
178
+ client.remove_label(IssueRemoveLabelRequest(id=issue_id, label_id=label_id))
179
+
180
+ # Update status: resolve a state by name, then set it
181
+ state = client.find_workflow_state(FindWorkflowStateRequest(team_id=team_id, name="In Progress")).state
182
+ client.set_issue_state(IssueSetStateRequest(id=issue_id, state_id=state.id))
183
+
184
+ # Full details: comments, attachments, project, cycle, parent, sub-issues, subscribers, relations
185
+ detail = client.issue_details(IssueRequest(id="ENG-123")).issue
186
+ print(detail.state.name, len(detail.comments), len(detail.attachments))
187
+ for child in detail.children:
188
+ print("sub-issue:", child.identifier, child.title)
189
+ ```
190
+
191
+ ## Escape hatch: raw GraphQL
192
+
193
+ Anything not covered by a convenience method can be run directly. `execute()`
194
+ returns the `data` object and raises on errors.
195
+
196
+ ```python
197
+ data = client.execute(
198
+ """
199
+ query($id: String!) {
200
+ issue(id: $id) { id title attachments { nodes { url title } } }
201
+ }
202
+ """,
203
+ {"id": "ENG-123"},
204
+ )
205
+ print(data["issue"]["attachments"]["nodes"])
206
+ ```
207
+
208
+ ## Errors
209
+
210
+ All exceptions subclass `LinearError`:
211
+
212
+ | Exception | Raised when |
213
+ |-----------|-------------|
214
+ | `LinearAuthenticationError` | Credentials are rejected (HTTP 401/403 or auth error code) |
215
+ | `LinearRateLimitError` | A rate limit is hit (`RATELIMITED`); carries the `X-RateLimit-*` header values |
216
+ | `LinearGraphQLError` | The API returns GraphQL `errors`; exposes `.errors` and `.code` |
217
+ | `LinearNetworkError` | The request never produced a usable response |
218
+
219
+ ```python
220
+ from linear_python_client import LinearClient, LinearRateLimitError, IssuesRequest
221
+
222
+ try:
223
+ client.issues(IssuesRequest(first=100))
224
+ except LinearRateLimitError as exc:
225
+ print("Rate limited; resets at", exc.requests_reset)
226
+ ```
227
+
228
+ ## Available client methods
229
+
230
+ Each method maps a `*Request` to a `*Response`:
231
+
232
+ | Method | Request | Response |
233
+ |--------|---------|----------|
234
+ | `viewer()` | – | `ViewerResponse` |
235
+ | `user(...)` | `UserRequest` | `UserResponse` |
236
+ | `users(...)` | `UsersRequest` | `UsersResponse` |
237
+ | `team(...)` | `TeamRequest` | `TeamResponse` |
238
+ | `teams(...)` | `TeamsRequest` | `TeamsResponse` |
239
+ | `issue(...)` | `IssueRequest` | `IssueResponse` |
240
+ | `issue_details(...)` | `IssueRequest` | `IssueDetailsResponse` |
241
+ | `issues(...)` | `IssuesRequest` | `IssuesResponse` |
242
+ | `create_issue(...)` | `IssueCreateRequest` | `CreateIssueResponse` |
243
+ | `update_issue(...)` | `IssueUpdateRequest` | `UpdateIssueResponse` |
244
+ | `archive_issue(...)` | `IssueArchiveRequest` | `ArchiveIssueResponse` |
245
+ | `add_label(...)` | `IssueAddLabelRequest` | `AddLabelResponse` |
246
+ | `remove_label(...)` | `IssueRemoveLabelRequest` | `RemoveLabelResponse` |
247
+ | `set_issue_state(...)` | `IssueSetStateRequest` | `UpdateIssueResponse` |
248
+ | `project(...)` | `ProjectRequest` | `ProjectResponse` |
249
+ | `projects(...)` | `ProjectsRequest` | `ProjectsResponse` |
250
+ | `comment(...)` | `CommentRequest` | `CommentResponse` |
251
+ | `comments(...)` | `CommentsRequest` | `CommentsResponse` |
252
+ | `create_comment(...)` | `CommentCreateRequest` | `CreateCommentResponse` |
253
+ | `workflow_states(...)` | `WorkflowStatesRequest` | `WorkflowStatesResponse` |
254
+ | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
255
+ | `issue_labels(...)` | `IssueLabelsRequest` | `IssueLabelsResponse` |
256
+ | `execute(query, variables)` | – | `dict` |
257
+ | `paginate(method, request)` | a `*Request` | iterator of nodes |
258
+
259
+ List requests are optional (e.g. `client.issues()` returns the first page unfiltered).
260
+
261
+ ## Development
262
+
263
+ ```sh
264
+ uv sync # install deps + dev tools
265
+ uv run pytest # run the mocked unit tests with coverage (no network)
266
+ uv run ruff check
267
+ ```
268
+
269
+ The test suite mocks the GraphQL endpoint, so no credentials or network access are
270
+ needed. An optional live smoke test runs only when `LINEAR_API_KEY` is set.
271
+
272
+ `pytest` runs with coverage by default and **fails under 90%** (configured in
273
+ `pyproject.toml`); the suite currently covers ~99% of the package. A coverage summary
274
+ prints after each run — add `--cov-report=html` for an annotated HTML report in
275
+ `htmlcov/`.
276
+
277
+ ### Live smoke test
278
+
279
+ `scripts/smoke_test.py` exercises **every** client method against the real Linear API
280
+ and, after each mutation, re-pulls the issue to confirm the change landed (create →
281
+ update → set status → add/remove label → comment → full details). It creates one
282
+ clearly-labelled test issue and archives it at the end, so it cleans up after itself.
283
+
284
+ ```sh
285
+ LINEAR_API_KEY=lin_api_... uv run python scripts/smoke_test.py
286
+ # optionally pin the team (defaults to the first one):
287
+ LINEAR_API_KEY=... LINEAR_TEAM_ID=<uuid> uv run python scripts/smoke_test.py
288
+ ```
289
+
290
+ It prints a ✓/✗ per check and exits non-zero if any fail. Because it writes to your
291
+ workspace, it's a manual script — it is not part of `pytest`.
292
+
293
+ ### Building & releasing
294
+
295
+ Build the distributions locally with uv:
296
+
297
+ ```sh
298
+ uv build # writes sdist + wheel to ./dist
299
+ uvx twine check dist/* # validate metadata / README rendering
300
+ ```
301
+
302
+ Releases are automated by [`.github/workflows/publish.yml`](.github/workflows/publish.yml).
303
+ On every push and PR it lints, tests (with the coverage gate), builds the sdist + wheel,
304
+ validates the metadata, and smoke-tests that the wheel installs and imports. When a
305
+ **GitHub Release is published**, it additionally publishes the build to PyPI — after
306
+ which `pip install linear-python-client` and `uv add linear-python-client` work.
307
+
308
+ Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
309
+ (OIDC), so no API token or secret is stored. One-time setup:
310
+
311
+ 1. On PyPI, add a trusted publisher for the project pointing at this repo, workflow
312
+ `publish.yml`, and environment `pypi`.
313
+ 2. In the repo, create a `pypi` [environment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment)
314
+ (Settings → Environments).
315
+
316
+ To cut a release: bump `version` in `pyproject.toml`, then create a matching GitHub
317
+ Release (e.g. tag `v0.1.0`) — the workflow builds and uploads it to PyPI.
318
+
319
+ > [!NOTE]
320
+ > `requires-python` is `>=3.14`, so installs require Python 3.14+.
321
+
322
+ ### Documentation
323
+
324
+ The docs are built with [MkDocs](https://www.mkdocs.org/) +
325
+ [Material](https://squidfunk.github.io/mkdocs-material/) and the API reference is
326
+ generated automatically from docstrings via
327
+ [mkdocstrings](https://mkdocstrings.github.io/).
328
+
329
+ ```sh
330
+ uv run --group docs mkdocs serve # live preview at http://127.0.0.1:8000
331
+ uv run --group docs mkdocs build --strict # production build into ./site
332
+ ```
333
+
334
+ They deploy to GitHub Pages automatically on every push to `main` via
335
+ [`.github/workflows/docs.yml`](.github/workflows/docs.yml). To enable publishing,
336
+ set **Settings → Pages → Build and deployment → Source** to **GitHub Actions** in the
337
+ repository once. Update the `site_url`/`repo_url` in `mkdocs.yml` if the repo lives
338
+ under a different owner.
@@ -0,0 +1,312 @@
1
+ # linear-python-client
2
+
3
+ A small, pragmatic synchronous Python client for the [Linear](https://linear.app)
4
+ GraphQL API. Linear's official SDK is TypeScript-only — this package gives Python
5
+ the same ergonomics, built on [Pydantic](https://docs.pydantic.dev/): every call
6
+ takes a typed **`*Request`** model and returns a dedicated **`*Response`** model, so
7
+ inputs and outputs are explicit and validated. A generic `execute()` escape hatch
8
+ covers anything the typed methods don't.
9
+
10
+ Built against the [Linear developer docs](https://linear.app/developers).
11
+
12
+ 📖 **Full documentation:** <https://hacker0x01.github.io/linear-python/>
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ uv add linear-python-client
18
+ # or
19
+ pip install linear-python-client
20
+ ```
21
+
22
+ Or for local development of this repo:
23
+
24
+ ```sh
25
+ uv sync
26
+ ```
27
+
28
+ Requires Python 3.14+.
29
+
30
+ ## Authentication
31
+
32
+ The client accepts either a personal API key or an OAuth 2.0 access token.
33
+
34
+ ```python
35
+ from linear_python_client import LinearClient
36
+
37
+ # Personal API key (sent as the raw `Authorization` header value)
38
+ client = LinearClient(api_key="lin_api_...")
39
+
40
+ # OAuth 2.0 access token (sent as `Authorization: Bearer <token>`)
41
+ client = LinearClient(access_token="...")
42
+
43
+ # Or set LINEAR_API_KEY in the environment and call LinearClient()
44
+ client = LinearClient()
45
+ ```
46
+
47
+ Use it as a context manager to close the underlying HTTP client automatically:
48
+
49
+ ```python
50
+ with LinearClient() as client:
51
+ print(client.viewer().viewer.name)
52
+ ```
53
+
54
+ ## Quickstart
55
+
56
+ Each method takes a `*Request` and returns a `*Response`:
57
+
58
+ ```python
59
+ from linear_python_client import (
60
+ LinearClient,
61
+ IssueRequest,
62
+ IssueCreateRequest,
63
+ IssueUpdateRequest,
64
+ IssueArchiveRequest,
65
+ CommentCreateRequest,
66
+ )
67
+
68
+ with LinearClient() as client:
69
+ # The authenticated user
70
+ me = client.viewer().viewer
71
+ print(me.name, me.email)
72
+
73
+ # Fetch a single issue by id or identifier
74
+ issue = client.issue(IssueRequest(id="ENG-123")).issue
75
+ print(issue.title, issue.state.name)
76
+
77
+ # Create an issue
78
+ created = client.create_issue(
79
+ IssueCreateRequest(
80
+ team_id="9cfb482a-81e3-4154-b5b9-2c805e70a02d",
81
+ title="New exception",
82
+ description="More detailed error report in **markdown**",
83
+ priority=2,
84
+ )
85
+ )
86
+ print(created.success, created.issue.identifier)
87
+
88
+ # Update it
89
+ client.update_issue(IssueUpdateRequest(id=created.issue.id, title="Renamed", priority=1))
90
+
91
+ # Comment on it
92
+ client.create_comment(CommentCreateRequest(issue_id=created.issue.id, body="On it 👍"))
93
+
94
+ # Archive it
95
+ client.archive_issue(IssueArchiveRequest(id=created.issue.id))
96
+ ```
97
+
98
+ Field names are Pythonic snake_case with camelCase aliases, so `IssueCreateRequest`
99
+ accepts `team_id=` (or `teamId=`) and the parsed models expose `issue.created_at`,
100
+ `issue.assignee.display_name`, and so on.
101
+
102
+ ## Listing, filtering & pagination
103
+
104
+ List methods take a `*Request` (with `first`, `after`, and a `filter` dict that maps
105
+ directly to Linear's [filtering syntax](https://linear.app/developers/filtering)) and
106
+ return a `*Response` that holds `.nodes` and `.page_info` (and is iterable).
107
+
108
+ ```python
109
+ from linear_python_client import IssuesRequest
110
+
111
+ # First 20 high-priority issues assigned to a specific user
112
+ resp = client.issues(
113
+ IssuesRequest(
114
+ first=20,
115
+ filter={
116
+ "priority": {"eq": 1},
117
+ "assignee": {"email": {"eq": "you@example.com"}},
118
+ },
119
+ order_by="updatedAt",
120
+ )
121
+ )
122
+ for issue in resp.nodes:
123
+ print(issue.identifier, issue.title)
124
+
125
+ print(resp.page_info.has_next_page, resp.page_info.end_cursor)
126
+ ```
127
+
128
+ Use `paginate()` to transparently follow the cursor across every page. Pass the list
129
+ method and a starting request:
130
+
131
+ ```python
132
+ for issue in client.paginate(client.issues, IssuesRequest(filter={"state": {"type": {"eq": "started"}}})):
133
+ print(issue.identifier, issue.title)
134
+ ```
135
+
136
+ `paginate()` works with any list method (`client.issues`, `client.teams`,
137
+ `client.projects`, `client.comments`, `client.users`, …) and its matching request.
138
+
139
+ ## Labels, status & full details
140
+
141
+ ```python
142
+ from linear_python_client import (
143
+ IssueAddLabelRequest,
144
+ IssueRemoveLabelRequest,
145
+ IssueSetStateRequest,
146
+ FindWorkflowStateRequest,
147
+ IssueRequest,
148
+ )
149
+
150
+ # Add / remove a single label without disturbing the issue's other labels
151
+ client.add_label(IssueAddLabelRequest(id=issue_id, label_id=label_id))
152
+ client.remove_label(IssueRemoveLabelRequest(id=issue_id, label_id=label_id))
153
+
154
+ # Update status: resolve a state by name, then set it
155
+ state = client.find_workflow_state(FindWorkflowStateRequest(team_id=team_id, name="In Progress")).state
156
+ client.set_issue_state(IssueSetStateRequest(id=issue_id, state_id=state.id))
157
+
158
+ # Full details: comments, attachments, project, cycle, parent, sub-issues, subscribers, relations
159
+ detail = client.issue_details(IssueRequest(id="ENG-123")).issue
160
+ print(detail.state.name, len(detail.comments), len(detail.attachments))
161
+ for child in detail.children:
162
+ print("sub-issue:", child.identifier, child.title)
163
+ ```
164
+
165
+ ## Escape hatch: raw GraphQL
166
+
167
+ Anything not covered by a convenience method can be run directly. `execute()`
168
+ returns the `data` object and raises on errors.
169
+
170
+ ```python
171
+ data = client.execute(
172
+ """
173
+ query($id: String!) {
174
+ issue(id: $id) { id title attachments { nodes { url title } } }
175
+ }
176
+ """,
177
+ {"id": "ENG-123"},
178
+ )
179
+ print(data["issue"]["attachments"]["nodes"])
180
+ ```
181
+
182
+ ## Errors
183
+
184
+ All exceptions subclass `LinearError`:
185
+
186
+ | Exception | Raised when |
187
+ |-----------|-------------|
188
+ | `LinearAuthenticationError` | Credentials are rejected (HTTP 401/403 or auth error code) |
189
+ | `LinearRateLimitError` | A rate limit is hit (`RATELIMITED`); carries the `X-RateLimit-*` header values |
190
+ | `LinearGraphQLError` | The API returns GraphQL `errors`; exposes `.errors` and `.code` |
191
+ | `LinearNetworkError` | The request never produced a usable response |
192
+
193
+ ```python
194
+ from linear_python_client import LinearClient, LinearRateLimitError, IssuesRequest
195
+
196
+ try:
197
+ client.issues(IssuesRequest(first=100))
198
+ except LinearRateLimitError as exc:
199
+ print("Rate limited; resets at", exc.requests_reset)
200
+ ```
201
+
202
+ ## Available client methods
203
+
204
+ Each method maps a `*Request` to a `*Response`:
205
+
206
+ | Method | Request | Response |
207
+ |--------|---------|----------|
208
+ | `viewer()` | – | `ViewerResponse` |
209
+ | `user(...)` | `UserRequest` | `UserResponse` |
210
+ | `users(...)` | `UsersRequest` | `UsersResponse` |
211
+ | `team(...)` | `TeamRequest` | `TeamResponse` |
212
+ | `teams(...)` | `TeamsRequest` | `TeamsResponse` |
213
+ | `issue(...)` | `IssueRequest` | `IssueResponse` |
214
+ | `issue_details(...)` | `IssueRequest` | `IssueDetailsResponse` |
215
+ | `issues(...)` | `IssuesRequest` | `IssuesResponse` |
216
+ | `create_issue(...)` | `IssueCreateRequest` | `CreateIssueResponse` |
217
+ | `update_issue(...)` | `IssueUpdateRequest` | `UpdateIssueResponse` |
218
+ | `archive_issue(...)` | `IssueArchiveRequest` | `ArchiveIssueResponse` |
219
+ | `add_label(...)` | `IssueAddLabelRequest` | `AddLabelResponse` |
220
+ | `remove_label(...)` | `IssueRemoveLabelRequest` | `RemoveLabelResponse` |
221
+ | `set_issue_state(...)` | `IssueSetStateRequest` | `UpdateIssueResponse` |
222
+ | `project(...)` | `ProjectRequest` | `ProjectResponse` |
223
+ | `projects(...)` | `ProjectsRequest` | `ProjectsResponse` |
224
+ | `comment(...)` | `CommentRequest` | `CommentResponse` |
225
+ | `comments(...)` | `CommentsRequest` | `CommentsResponse` |
226
+ | `create_comment(...)` | `CommentCreateRequest` | `CreateCommentResponse` |
227
+ | `workflow_states(...)` | `WorkflowStatesRequest` | `WorkflowStatesResponse` |
228
+ | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
229
+ | `issue_labels(...)` | `IssueLabelsRequest` | `IssueLabelsResponse` |
230
+ | `execute(query, variables)` | – | `dict` |
231
+ | `paginate(method, request)` | a `*Request` | iterator of nodes |
232
+
233
+ List requests are optional (e.g. `client.issues()` returns the first page unfiltered).
234
+
235
+ ## Development
236
+
237
+ ```sh
238
+ uv sync # install deps + dev tools
239
+ uv run pytest # run the mocked unit tests with coverage (no network)
240
+ uv run ruff check
241
+ ```
242
+
243
+ The test suite mocks the GraphQL endpoint, so no credentials or network access are
244
+ needed. An optional live smoke test runs only when `LINEAR_API_KEY` is set.
245
+
246
+ `pytest` runs with coverage by default and **fails under 90%** (configured in
247
+ `pyproject.toml`); the suite currently covers ~99% of the package. A coverage summary
248
+ prints after each run — add `--cov-report=html` for an annotated HTML report in
249
+ `htmlcov/`.
250
+
251
+ ### Live smoke test
252
+
253
+ `scripts/smoke_test.py` exercises **every** client method against the real Linear API
254
+ and, after each mutation, re-pulls the issue to confirm the change landed (create →
255
+ update → set status → add/remove label → comment → full details). It creates one
256
+ clearly-labelled test issue and archives it at the end, so it cleans up after itself.
257
+
258
+ ```sh
259
+ LINEAR_API_KEY=lin_api_... uv run python scripts/smoke_test.py
260
+ # optionally pin the team (defaults to the first one):
261
+ LINEAR_API_KEY=... LINEAR_TEAM_ID=<uuid> uv run python scripts/smoke_test.py
262
+ ```
263
+
264
+ It prints a ✓/✗ per check and exits non-zero if any fail. Because it writes to your
265
+ workspace, it's a manual script — it is not part of `pytest`.
266
+
267
+ ### Building & releasing
268
+
269
+ Build the distributions locally with uv:
270
+
271
+ ```sh
272
+ uv build # writes sdist + wheel to ./dist
273
+ uvx twine check dist/* # validate metadata / README rendering
274
+ ```
275
+
276
+ Releases are automated by [`.github/workflows/publish.yml`](.github/workflows/publish.yml).
277
+ On every push and PR it lints, tests (with the coverage gate), builds the sdist + wheel,
278
+ validates the metadata, and smoke-tests that the wheel installs and imports. When a
279
+ **GitHub Release is published**, it additionally publishes the build to PyPI — after
280
+ which `pip install linear-python-client` and `uv add linear-python-client` work.
281
+
282
+ Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
283
+ (OIDC), so no API token or secret is stored. One-time setup:
284
+
285
+ 1. On PyPI, add a trusted publisher for the project pointing at this repo, workflow
286
+ `publish.yml`, and environment `pypi`.
287
+ 2. In the repo, create a `pypi` [environment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment)
288
+ (Settings → Environments).
289
+
290
+ To cut a release: bump `version` in `pyproject.toml`, then create a matching GitHub
291
+ Release (e.g. tag `v0.1.0`) — the workflow builds and uploads it to PyPI.
292
+
293
+ > [!NOTE]
294
+ > `requires-python` is `>=3.14`, so installs require Python 3.14+.
295
+
296
+ ### Documentation
297
+
298
+ The docs are built with [MkDocs](https://www.mkdocs.org/) +
299
+ [Material](https://squidfunk.github.io/mkdocs-material/) and the API reference is
300
+ generated automatically from docstrings via
301
+ [mkdocstrings](https://mkdocstrings.github.io/).
302
+
303
+ ```sh
304
+ uv run --group docs mkdocs serve # live preview at http://127.0.0.1:8000
305
+ uv run --group docs mkdocs build --strict # production build into ./site
306
+ ```
307
+
308
+ They deploy to GitHub Pages automatically on every push to `main` via
309
+ [`.github/workflows/docs.yml`](.github/workflows/docs.yml). To enable publishing,
310
+ set **Settings → Pages → Build and deployment → Source** to **GitHub Actions** in the
311
+ repository once. Update the `site_url`/`repo_url` in `mkdocs.yml` if the repo lives
312
+ under a different owner.