linear-python-client 0.1.1__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linear-python-client
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Pragmatic Python client for the Linear GraphQL API
5
5
  Keywords: linear,linear.app,graphql,api,client,sdk
6
6
  Author: eli-the-wizard
@@ -11,12 +11,12 @@ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Dist: httpx>=0.27
18
18
  Requires-Dist: pydantic>=2.7
19
- Requires-Python: >=3.14
19
+ Requires-Python: >=3.13
20
20
  Project-URL: Homepage, https://github.com/Hacker0x01/linear-python-client
21
21
  Project-URL: Documentation, https://hacker0x01.github.io/linear-python-client/
22
22
  Project-URL: Repository, https://github.com/Hacker0x01/linear-python-client
@@ -50,7 +50,7 @@ Or for local development of this repo:
50
50
  uv sync
51
51
  ```
52
52
 
53
- Requires Python 3.14+.
53
+ Requires Python 3.13+.
54
54
 
55
55
  ## Authentication
56
56
 
@@ -187,6 +187,33 @@ for child in detail.children:
187
187
  print("sub-issue:", child.identifier, child.title)
188
188
  ```
189
189
 
190
+ ## Looking things up by name (instead of UUIDs)
191
+
192
+ Most calls take UUIDs. Use the `find_*` resolvers to turn a human name/key/email into
193
+ the entity (and its `.id`) first:
194
+
195
+ ```python
196
+ from linear_python_client import (
197
+ FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
198
+ IssueCreateRequest,
199
+ )
200
+
201
+ team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
202
+ assignee = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email=...
203
+ bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
204
+
205
+ client.create_issue(IssueCreateRequest(
206
+ team_id=team.id,
207
+ title="New issue",
208
+ assignee_id=assignee.id,
209
+ label_ids=[bug.id],
210
+ ))
211
+ ```
212
+
213
+ Each resolver returns the matching entity, or `None` if nothing matches. Name matching
214
+ is case-insensitive; team `key` is matched exactly. `find_workflow_state` (for statuses)
215
+ works the same way.
216
+
190
217
  ## Escape hatch: raw GraphQL
191
218
 
192
219
  Anything not covered by a convenience method can be run directly. `execute()`
@@ -250,8 +277,12 @@ Each method maps a `*Request` to a `*Response`:
250
277
  | `comments(...)` | `CommentsRequest` | `CommentsResponse` |
251
278
  | `create_comment(...)` | `CommentCreateRequest` | `CreateCommentResponse` |
252
279
  | `workflow_states(...)` | `WorkflowStatesRequest` | `WorkflowStatesResponse` |
253
- | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
254
280
  | `issue_labels(...)` | `IssueLabelsRequest` | `IssueLabelsResponse` |
281
+ | `find_team(...)` | `FindTeamRequest` | `TeamResponse` |
282
+ | `find_user(...)` | `FindUserRequest` | `UserResponse` |
283
+ | `find_project(...)` | `FindProjectRequest` | `ProjectResponse` |
284
+ | `find_label(...)` | `FindLabelRequest` | `IssueLabelResponse` |
285
+ | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
255
286
  | `execute(query, variables)` | – | `dict` |
256
287
  | `paginate(method, request)` | a `*Request` | iterator of nodes |
257
288
 
@@ -316,7 +347,7 @@ To cut a release: bump `version` in `pyproject.toml`, then create a matching Git
316
347
  Release (e.g. tag `v0.1.1`) — the workflow builds and uploads it to PyPI.
317
348
 
318
349
  > [!NOTE]
319
- > `requires-python` is `>=3.14`, so installs require Python 3.14+.
350
+ > `requires-python` is `>=3.13`, so installs require Python 3.13+.
320
351
 
321
352
  ### Documentation
322
353
 
@@ -25,7 +25,7 @@ Or for local development of this repo:
25
25
  uv sync
26
26
  ```
27
27
 
28
- Requires Python 3.14+.
28
+ Requires Python 3.13+.
29
29
 
30
30
  ## Authentication
31
31
 
@@ -162,6 +162,33 @@ for child in detail.children:
162
162
  print("sub-issue:", child.identifier, child.title)
163
163
  ```
164
164
 
165
+ ## Looking things up by name (instead of UUIDs)
166
+
167
+ Most calls take UUIDs. Use the `find_*` resolvers to turn a human name/key/email into
168
+ the entity (and its `.id`) first:
169
+
170
+ ```python
171
+ from linear_python_client import (
172
+ FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
173
+ IssueCreateRequest,
174
+ )
175
+
176
+ team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
177
+ assignee = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email=...
178
+ bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
179
+
180
+ client.create_issue(IssueCreateRequest(
181
+ team_id=team.id,
182
+ title="New issue",
183
+ assignee_id=assignee.id,
184
+ label_ids=[bug.id],
185
+ ))
186
+ ```
187
+
188
+ Each resolver returns the matching entity, or `None` if nothing matches. Name matching
189
+ is case-insensitive; team `key` is matched exactly. `find_workflow_state` (for statuses)
190
+ works the same way.
191
+
165
192
  ## Escape hatch: raw GraphQL
166
193
 
167
194
  Anything not covered by a convenience method can be run directly. `execute()`
@@ -225,8 +252,12 @@ Each method maps a `*Request` to a `*Response`:
225
252
  | `comments(...)` | `CommentsRequest` | `CommentsResponse` |
226
253
  | `create_comment(...)` | `CommentCreateRequest` | `CreateCommentResponse` |
227
254
  | `workflow_states(...)` | `WorkflowStatesRequest` | `WorkflowStatesResponse` |
228
- | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
229
255
  | `issue_labels(...)` | `IssueLabelsRequest` | `IssueLabelsResponse` |
256
+ | `find_team(...)` | `FindTeamRequest` | `TeamResponse` |
257
+ | `find_user(...)` | `FindUserRequest` | `UserResponse` |
258
+ | `find_project(...)` | `FindProjectRequest` | `ProjectResponse` |
259
+ | `find_label(...)` | `FindLabelRequest` | `IssueLabelResponse` |
260
+ | `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
230
261
  | `execute(query, variables)` | – | `dict` |
231
262
  | `paginate(method, request)` | a `*Request` | iterator of nodes |
232
263
 
@@ -291,7 +322,7 @@ To cut a release: bump `version` in `pyproject.toml`, then create a matching Git
291
322
  Release (e.g. tag `v0.1.1`) — the workflow builds and uploads it to PyPI.
292
323
 
293
324
  > [!NOTE]
294
- > `requires-python` is `>=3.14`, so installs require Python 3.14+.
325
+ > `requires-python` is `>=3.13`, so installs require Python 3.13+.
295
326
 
296
327
  ### Documentation
297
328
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "linear-python-client"
3
- version = "0.1.1"
3
+ version = "0.2.1"
4
4
  description = "Pragmatic Python client for the Linear GraphQL API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,14 +8,14 @@ license-files = ["LICENSE"]
8
8
  authors = [
9
9
  { name = "eli-the-wizard", email = "ewinter@hackerone.com" }
10
10
  ]
11
- requires-python = ">=3.14"
11
+ requires-python = ">=3.13"
12
12
  keywords = ["linear", "linear.app", "graphql", "api", "client", "sdk"]
13
13
  classifiers = [
14
14
  "Development Status :: 4 - Beta",
15
15
  "Intended Audience :: Developers",
16
16
  "Operating System :: OS Independent",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.14",
18
+ "Programming Language :: Python :: 3.13",
19
19
  "Topic :: Software Development :: Libraries :: Python Modules",
20
20
  "Typing :: Typed",
21
21
  ]
@@ -29,6 +29,10 @@ from .models.requests import (
29
29
  CommentCreateRequest,
30
30
  CommentRequest,
31
31
  CommentsRequest,
32
+ FindLabelRequest,
33
+ FindProjectRequest,
34
+ FindTeamRequest,
35
+ FindUserRequest,
32
36
  FindWorkflowStateRequest,
33
37
  IssueAddLabelRequest,
34
38
  IssueArchiveRequest,
@@ -57,6 +61,7 @@ from .models.responses import (
57
61
  CreateCommentResponse,
58
62
  CreateIssueResponse,
59
63
  IssueDetailsResponse,
64
+ IssueLabelResponse,
60
65
  IssueLabelsResponse,
61
66
  IssueResponse,
62
67
  IssuesResponse,
@@ -73,7 +78,7 @@ from .models.responses import (
73
78
  WorkflowStatesResponse,
74
79
  )
75
80
 
76
- __version__ = "0.1.1"
81
+ __version__ = "0.2.1"
77
82
 
78
83
  __all__ = [
79
84
  "DEFAULT_ENDPOINT",
@@ -113,6 +118,10 @@ __all__ = [
113
118
  "IssueRemoveLabelRequest",
114
119
  "IssueSetStateRequest",
115
120
  "FindWorkflowStateRequest",
121
+ "FindTeamRequest",
122
+ "FindUserRequest",
123
+ "FindLabelRequest",
124
+ "FindProjectRequest",
116
125
  "ProjectRequest",
117
126
  "ProjectsRequest",
118
127
  "CommentRequest",
@@ -142,6 +151,7 @@ __all__ = [
142
151
  "CreateCommentResponse",
143
152
  "WorkflowStateResponse",
144
153
  "WorkflowStatesResponse",
154
+ "IssueLabelResponse",
145
155
  "IssueLabelsResponse",
146
156
  "__version__",
147
157
  ]
@@ -24,6 +24,10 @@ from .models.requests import (
24
24
  CommentCreateRequest,
25
25
  CommentRequest,
26
26
  CommentsRequest,
27
+ FindLabelRequest,
28
+ FindProjectRequest,
29
+ FindTeamRequest,
30
+ FindUserRequest,
27
31
  FindWorkflowStateRequest,
28
32
  IssueAddLabelRequest,
29
33
  IssueArchiveRequest,
@@ -52,6 +56,7 @@ from .models.responses import (
52
56
  CreateCommentResponse,
53
57
  CreateIssueResponse,
54
58
  IssueDetailsResponse,
59
+ IssueLabelResponse,
55
60
  IssueLabelsResponse,
56
61
  IssueResponse,
57
62
  IssuesResponse,
@@ -83,6 +88,12 @@ def _to_int(value: str | None) -> int | None:
83
88
  return None
84
89
 
85
90
 
91
+ def _first_node(data: dict[str, Any], key: str) -> dict[str, Any] | None:
92
+ """Return the first node of a connection in ``data[key]``, or ``None``."""
93
+ nodes = (data.get(key) or {}).get("nodes") or []
94
+ return nodes[0] if nodes else None
95
+
96
+
86
97
  class LinearClient:
87
98
  """Client for Linear's GraphQL API.
88
99
 
@@ -556,17 +567,66 @@ class LinearClient:
556
567
  A [`WorkflowStateResponse`][linear_python_client.WorkflowStateResponse];
557
568
  `.state` is `None` if no state matches.
558
569
  """
559
- variables = {
560
- "first": 1,
561
- "after": None,
562
- "filter": {
563
- "team": {"id": {"eq": request.team_id}},
564
- "name": {"eqIgnoreCase": request.name},
565
- },
570
+ filter_ = {
571
+ "team": {"id": {"eq": request.team_id}},
572
+ "name": {"eqIgnoreCase": request.name},
566
573
  }
567
- data = self.execute(queries.WORKFLOW_STATES, variables)
568
- nodes = (data.get("workflowStates") or {}).get("nodes") or []
569
- return WorkflowStateResponse.model_validate({"state": nodes[0] if nodes else None})
574
+ data = self.execute(queries.WORKFLOW_STATES, {"first": 1, "filter": filter_})
575
+ return WorkflowStateResponse.model_validate({"state": _first_node(data, "workflowStates")})
576
+
577
+ def find_team(self, request: FindTeamRequest) -> TeamResponse:
578
+ """Resolve a team by display name or key.
579
+
580
+ Args:
581
+ request: A [`FindTeamRequest`][linear_python_client.FindTeamRequest]
582
+ with `name` and/or `key`.
583
+
584
+ Returns:
585
+ A [`TeamResponse`][linear_python_client.TeamResponse]; `.team` is
586
+ `None` if no team matches.
587
+ """
588
+ data = self.execute(queries.TEAMS, {"first": 1, "filter": request.to_filter()})
589
+ return TeamResponse.model_validate({"team": _first_node(data, "teams")})
590
+
591
+ def find_user(self, request: FindUserRequest) -> UserResponse:
592
+ """Resolve a user by name, display name, or email.
593
+
594
+ Args:
595
+ request: A [`FindUserRequest`][linear_python_client.FindUserRequest]
596
+ with `name` and/or `email`.
597
+
598
+ Returns:
599
+ A [`UserResponse`][linear_python_client.UserResponse]; `.user` is
600
+ `None` if no user matches.
601
+ """
602
+ data = self.execute(queries.USERS, {"first": 1, "filter": request.to_filter()})
603
+ return UserResponse.model_validate({"user": _first_node(data, "users")})
604
+
605
+ def find_project(self, request: FindProjectRequest) -> ProjectResponse:
606
+ """Resolve a project by name (case-insensitive).
607
+
608
+ Args:
609
+ request: A [`FindProjectRequest`][linear_python_client.FindProjectRequest].
610
+
611
+ Returns:
612
+ A [`ProjectResponse`][linear_python_client.ProjectResponse]; `.project`
613
+ is `None` if no project matches.
614
+ """
615
+ data = self.execute(queries.PROJECTS, {"first": 1, "filter": request.to_filter()})
616
+ return ProjectResponse.model_validate({"project": _first_node(data, "projects")})
617
+
618
+ def find_label(self, request: FindLabelRequest) -> IssueLabelResponse:
619
+ """Resolve an issue label by name, optionally scoped to a team.
620
+
621
+ Args:
622
+ request: A [`FindLabelRequest`][linear_python_client.FindLabelRequest].
623
+
624
+ Returns:
625
+ An [`IssueLabelResponse`][linear_python_client.IssueLabelResponse];
626
+ `.label` is `None` if no label matches.
627
+ """
628
+ data = self.execute(queries.ISSUE_LABELS, {"first": 1, "filter": request.to_filter()})
629
+ return IssueLabelResponse.model_validate({"label": _first_node(data, "issueLabels")})
570
630
 
571
631
  def issue_labels(self, request: IssueLabelsRequest | None = None) -> IssueLabelsResponse:
572
632
  """List issue labels in the workspace.
@@ -25,6 +25,10 @@ from .requests import (
25
25
  CommentCreateRequest,
26
26
  CommentRequest,
27
27
  CommentsRequest,
28
+ FindLabelRequest,
29
+ FindProjectRequest,
30
+ FindTeamRequest,
31
+ FindUserRequest,
28
32
  FindWorkflowStateRequest,
29
33
  IssueAddLabelRequest,
30
34
  IssueArchiveRequest,
@@ -53,6 +57,7 @@ from .responses import (
53
57
  CreateCommentResponse,
54
58
  CreateIssueResponse,
55
59
  IssueDetailsResponse,
60
+ IssueLabelResponse,
56
61
  IssueLabelsResponse,
57
62
  IssueResponse,
58
63
  IssuesResponse,
@@ -99,6 +104,10 @@ __all__ = [
99
104
  "IssueRemoveLabelRequest",
100
105
  "IssueSetStateRequest",
101
106
  "FindWorkflowStateRequest",
107
+ "FindTeamRequest",
108
+ "FindUserRequest",
109
+ "FindLabelRequest",
110
+ "FindProjectRequest",
102
111
  "ProjectRequest",
103
112
  "ProjectsRequest",
104
113
  "CommentRequest",
@@ -128,5 +137,6 @@ __all__ = [
128
137
  "CreateCommentResponse",
129
138
  "WorkflowStateResponse",
130
139
  "WorkflowStatesResponse",
140
+ "IssueLabelResponse",
131
141
  "IssueLabelsResponse",
132
142
  ]
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any
11
11
 
12
- from pydantic import ConfigDict, Field
12
+ from pydantic import ConfigDict, Field, model_validator
13
13
 
14
14
  from .entities import LinearModel
15
15
 
@@ -239,6 +239,106 @@ class FindWorkflowStateRequest(LinearModel):
239
239
  name: str
240
240
 
241
241
 
242
+ class FindTeamRequest(LinearModel):
243
+ """Resolve a team by its display name or key.
244
+
245
+ Provide at least one of `name` / `key`. Matching is case-insensitive for the
246
+ name and exact for the key.
247
+
248
+ Attributes:
249
+ name: Team display name (e.g. `"Ravens"`).
250
+ key: Team key (e.g. `"RAV"`).
251
+ """
252
+
253
+ name: str | None = None
254
+ key: str | None = None
255
+
256
+ @model_validator(mode="after")
257
+ def _require_one(self) -> FindTeamRequest:
258
+ if not (self.name or self.key):
259
+ raise ValueError("FindTeamRequest requires at least one of `name` or `key`.")
260
+ return self
261
+
262
+ def to_filter(self) -> dict[str, Any]:
263
+ """Build the `TeamFilter` for this lookup."""
264
+ filter_: dict[str, Any] = {}
265
+ if self.key:
266
+ filter_["key"] = {"eq": self.key}
267
+ if self.name:
268
+ filter_["name"] = {"eqIgnoreCase": self.name}
269
+ return filter_
270
+
271
+
272
+ class FindUserRequest(LinearModel):
273
+ """Resolve a user by name, display name, or email.
274
+
275
+ Provide at least one of `name` / `email`. The `name` value is matched
276
+ (case-insensitively) against both the full name and the display name.
277
+
278
+ Attributes:
279
+ name: Full name or display name (e.g. `"Elijah Winter"`).
280
+ email: Email address.
281
+ """
282
+
283
+ name: str | None = None
284
+ email: str | None = None
285
+
286
+ @model_validator(mode="after")
287
+ def _require_one(self) -> FindUserRequest:
288
+ if not (self.name or self.email):
289
+ raise ValueError("FindUserRequest requires at least one of `name` or `email`.")
290
+ return self
291
+
292
+ def to_filter(self) -> dict[str, Any]:
293
+ """Build the `UserFilter` for this lookup."""
294
+ clauses: list[dict[str, Any]] = []
295
+ if self.name:
296
+ clauses.append(
297
+ {
298
+ "or": [
299
+ {"name": {"eqIgnoreCase": self.name}},
300
+ {"displayName": {"eqIgnoreCase": self.name}},
301
+ ]
302
+ }
303
+ )
304
+ if self.email:
305
+ clauses.append({"email": {"eqIgnoreCase": self.email}})
306
+ return clauses[0] if len(clauses) == 1 else {"and": clauses}
307
+
308
+
309
+ class FindLabelRequest(LinearModel):
310
+ """Resolve an issue label by name, optionally scoped to a team.
311
+
312
+ Attributes:
313
+ name: Label name to match, case-insensitively (e.g. `"bug"`).
314
+ team_id: Optional team UUID to disambiguate team-scoped labels.
315
+ """
316
+
317
+ name: str
318
+ team_id: str | None = None
319
+
320
+ def to_filter(self) -> dict[str, Any]:
321
+ """Build the `IssueLabelFilter` for this lookup."""
322
+ filter_: dict[str, Any] = {"name": {"eqIgnoreCase": self.name}}
323
+ if self.team_id:
324
+ filter_["team"] = {"id": {"eq": self.team_id}}
325
+ return filter_
326
+
327
+
328
+ class FindProjectRequest(LinearModel):
329
+ """Resolve a project by name.
330
+
331
+ Attributes:
332
+ name: Project name to match, case-insensitively.
333
+ """
334
+
335
+ name: str
336
+
337
+ def to_filter(self) -> dict[str, Any]:
338
+ """Build the `ProjectFilter` for this lookup."""
339
+ return {"name": {"eqIgnoreCase": self.name}}
340
+
341
+
242
342
  class CommentCreateRequest(LinearModel):
243
343
  """Input for adding a comment to an issue.
244
344
 
@@ -82,6 +82,12 @@ class WorkflowStateResponse(LinearModel):
82
82
  state: WorkflowState | None = None
83
83
 
84
84
 
85
+ class IssueLabelResponse(LinearModel):
86
+ """Resolved issue label from `find_label` (`None` if no match)."""
87
+
88
+ label: IssueLabel | None = None
89
+
90
+
85
91
  class ProjectResponse(LinearModel):
86
92
  """Response for [`project`][linear_python_client.client.LinearClient.project]."""
87
93