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.
- linear_python_client-0.1.0/LICENSE +21 -0
- linear_python_client-0.1.0/PKG-INFO +338 -0
- linear_python_client-0.1.0/README.md +312 -0
- linear_python_client-0.1.0/pyproject.toml +68 -0
- linear_python_client-0.1.0/src/linear_python_client/__init__.py +147 -0
- linear_python_client-0.1.0/src/linear_python_client/client.py +627 -0
- linear_python_client-0.1.0/src/linear_python_client/errors.py +78 -0
- linear_python_client-0.1.0/src/linear_python_client/graphql/__init__.py +10 -0
- linear_python_client-0.1.0/src/linear_python_client/graphql/queries.py +405 -0
- linear_python_client-0.1.0/src/linear_python_client/models/__init__.py +132 -0
- linear_python_client-0.1.0/src/linear_python_client/models/entities.py +187 -0
- linear_python_client-0.1.0/src/linear_python_client/models/requests.py +260 -0
- linear_python_client-0.1.0/src/linear_python_client/models/responses.py +169 -0
- linear_python_client-0.1.0/src/linear_python_client/py.typed +0 -0
|
@@ -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.
|