Skip to main content

Pragmatic Python client for the Linear GraphQL API

Project description

linear-python-client

A small, pragmatic synchronous Python client for the Linear GraphQL API. Linear's official SDK is TypeScript-only — this package gives Python the same ergonomics, built on Pydantic: every call takes a typed *Request model and returns a dedicated *Response model, so inputs and outputs are explicit and validated. A generic execute() escape hatch covers anything the typed methods don't.

Built against the Linear developer docs.

📖 Full documentation: https://hacker0x01.github.io/linear-python-client/

Installation

uv add linear-python-client
# or
pip install linear-python-client

Or for local development of this repo:

uv sync

Requires Python 3.14+.

Authentication

The client accepts either a personal API key or an OAuth 2.0 access token.

from linear_python_client import LinearClient

# Personal API key (sent as the raw `Authorization` header value)
client = LinearClient(api_key="lin_api_...")

# OAuth 2.0 access token (sent as `Authorization: Bearer <token>`)
client = LinearClient(access_token="...")

# Or set LINEAR_API_KEY in the environment and call LinearClient()
client = LinearClient()

Use it as a context manager to close the underlying HTTP client automatically:

with LinearClient() as client:
    print(client.viewer().viewer.name)

Quickstart

Each method takes a *Request and returns a *Response:

from linear_python_client import (
    LinearClient,
    IssueRequest,
    IssueCreateRequest,
    IssueUpdateRequest,
    IssueArchiveRequest,
    CommentCreateRequest,
)

with LinearClient() as client:
    # The authenticated user
    me = client.viewer().viewer
    print(me.name, me.email)

    # Fetch a single issue by id or identifier
    issue = client.issue(IssueRequest(id="ENG-123")).issue
    print(issue.title, issue.state.name)

    # Create an issue
    created = client.create_issue(
        IssueCreateRequest(
            team_id="9cfb482a-81e3-4154-b5b9-2c805e70a02d",
            title="New exception",
            description="More detailed error report in **markdown**",
            priority=2,
        )
    )
    print(created.success, created.issue.identifier)

    # Update it
    client.update_issue(IssueUpdateRequest(id=created.issue.id, title="Renamed", priority=1))

    # Comment on it
    client.create_comment(CommentCreateRequest(issue_id=created.issue.id, body="On it 👍"))

    # Archive it
    client.archive_issue(IssueArchiveRequest(id=created.issue.id))

Field names are Pythonic snake_case with camelCase aliases, so IssueCreateRequest accepts team_id= (or teamId=) and the parsed models expose issue.created_at, issue.assignee.display_name, and so on.

Listing, filtering & pagination

List methods take a *Request (with first, after, and a filter dict that maps directly to Linear's filtering syntax) and return a *Response that holds .nodes and .page_info (and is iterable).

from linear_python_client import IssuesRequest

# First 20 high-priority issues assigned to a specific user
resp = client.issues(
    IssuesRequest(
        first=20,
        filter={
            "priority": {"eq": 1},
            "assignee": {"email": {"eq": "you@example.com"}},
        },
        order_by="updatedAt",
    )
)
for issue in resp.nodes:
    print(issue.identifier, issue.title)

print(resp.page_info.has_next_page, resp.page_info.end_cursor)

Use paginate() to transparently follow the cursor across every page. Pass the list method and a starting request:

for issue in client.paginate(client.issues, IssuesRequest(filter={"state": {"type": {"eq": "started"}}})):
    print(issue.identifier, issue.title)

paginate() works with any list method (client.issues, client.teams, client.projects, client.comments, client.users, …) and its matching request.

Labels, status & full details

from linear_python_client import (
    IssueAddLabelRequest,
    IssueRemoveLabelRequest,
    IssueSetStateRequest,
    FindWorkflowStateRequest,
    IssueRequest,
)

# Add / remove a single label without disturbing the issue's other labels
client.add_label(IssueAddLabelRequest(id=issue_id, label_id=label_id))
client.remove_label(IssueRemoveLabelRequest(id=issue_id, label_id=label_id))

# Update status: resolve a state by name, then set it
state = client.find_workflow_state(FindWorkflowStateRequest(team_id=team_id, name="In Progress")).state
client.set_issue_state(IssueSetStateRequest(id=issue_id, state_id=state.id))

# Full details: comments, attachments, project, cycle, parent, sub-issues, subscribers, relations
detail = client.issue_details(IssueRequest(id="ENG-123")).issue
print(detail.state.name, len(detail.comments), len(detail.attachments))
for child in detail.children:
    print("sub-issue:", child.identifier, child.title)

Looking things up by name (instead of UUIDs)

Most calls take UUIDs. Use the find_* resolvers to turn a human name/key/email into the entity (and its .id) first:

from linear_python_client import (
    FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
    IssueCreateRequest,
)

team = client.find_team(FindTeamRequest(key="RAV")).team        # or name="Ravens"
assignee = client.find_user(FindUserRequest(name="Elijah Winter")).user  # or email=...
bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label

client.create_issue(IssueCreateRequest(
    team_id=team.id,
    title="New issue",
    assignee_id=assignee.id,
    label_ids=[bug.id],
))

Each resolver returns the matching entity, or None if nothing matches. Name matching is case-insensitive; team key is matched exactly. find_workflow_state (for statuses) works the same way.

Escape hatch: raw GraphQL

Anything not covered by a convenience method can be run directly. execute() returns the data object and raises on errors.

data = client.execute(
    """
    query($id: String!) {
      issue(id: $id) { id title attachments { nodes { url title } } }
    }
    """,
    {"id": "ENG-123"},
)
print(data["issue"]["attachments"]["nodes"])

Errors

All exceptions subclass LinearError:

Exception Raised when
LinearAuthenticationError Credentials are rejected (HTTP 401/403 or auth error code)
LinearRateLimitError A rate limit is hit (RATELIMITED); carries the X-RateLimit-* header values
LinearGraphQLError The API returns GraphQL errors; exposes .errors and .code
LinearNetworkError The request never produced a usable response
from linear_python_client import LinearClient, LinearRateLimitError, IssuesRequest

try:
    client.issues(IssuesRequest(first=100))
except LinearRateLimitError as exc:
    print("Rate limited; resets at", exc.requests_reset)

Available client methods

Each method maps a *Request to a *Response:

Method Request Response
viewer() ViewerResponse
user(...) UserRequest UserResponse
users(...) UsersRequest UsersResponse
team(...) TeamRequest TeamResponse
teams(...) TeamsRequest TeamsResponse
issue(...) IssueRequest IssueResponse
issue_details(...) IssueRequest IssueDetailsResponse
issues(...) IssuesRequest IssuesResponse
create_issue(...) IssueCreateRequest CreateIssueResponse
update_issue(...) IssueUpdateRequest UpdateIssueResponse
archive_issue(...) IssueArchiveRequest ArchiveIssueResponse
add_label(...) IssueAddLabelRequest AddLabelResponse
remove_label(...) IssueRemoveLabelRequest RemoveLabelResponse
set_issue_state(...) IssueSetStateRequest UpdateIssueResponse
project(...) ProjectRequest ProjectResponse
projects(...) ProjectsRequest ProjectsResponse
comment(...) CommentRequest CommentResponse
comments(...) CommentsRequest CommentsResponse
create_comment(...) CommentCreateRequest CreateCommentResponse
workflow_states(...) WorkflowStatesRequest WorkflowStatesResponse
issue_labels(...) IssueLabelsRequest IssueLabelsResponse
find_team(...) FindTeamRequest TeamResponse
find_user(...) FindUserRequest UserResponse
find_project(...) FindProjectRequest ProjectResponse
find_label(...) FindLabelRequest IssueLabelResponse
find_workflow_state(...) FindWorkflowStateRequest WorkflowStateResponse
execute(query, variables) dict
paginate(method, request) a *Request iterator of nodes

List requests are optional (e.g. client.issues() returns the first page unfiltered).

Development

uv sync          # install deps + dev tools
uv run pytest    # run the mocked unit tests with coverage (no network)
uv run ruff check

The test suite mocks the GraphQL endpoint, so no credentials or network access are needed. An optional live smoke test runs only when LINEAR_API_KEY is set.

pytest runs with coverage by default and fails under 90% (configured in pyproject.toml); the suite currently covers ~99% of the package. A coverage summary prints after each run — add --cov-report=html for an annotated HTML report in htmlcov/.

Live smoke test

scripts/smoke_test.py exercises every client method against the real Linear API and, after each mutation, re-pulls the issue to confirm the change landed (create → update → set status → add/remove label → comment → full details). It creates one clearly-labelled test issue and archives it at the end, so it cleans up after itself.

LINEAR_API_KEY=lin_api_... uv run python scripts/smoke_test.py
# optionally pin the team (defaults to the first one):
LINEAR_API_KEY=... LINEAR_TEAM_ID=<uuid> uv run python scripts/smoke_test.py

It prints a ✓/✗ per check and exits non-zero if any fail. Because it writes to your workspace, it's a manual script — it is not part of pytest.

Building & releasing

Build the distributions locally with uv:

uv build              # writes sdist + wheel to ./dist
uvx twine check dist/*  # validate metadata / README rendering

Releases are automated by .github/workflows/publish.yml. On every push and PR it lints, tests (with the coverage gate), builds the sdist + wheel, validates the metadata, and smoke-tests that the wheel installs and imports. When a GitHub Release is published, it additionally publishes the build to PyPI — after which pip install linear-python-client and uv add linear-python-client work.

Publishing uses PyPI Trusted Publishing (OIDC), so no API token or secret is stored. One-time setup:

  1. On PyPI, add a trusted publisher for the project pointing at this repo, workflow publish.yml, and environment pypi.
  2. In the repo, create a pypi environment (Settings → Environments).

To cut a release: bump version in pyproject.toml, then create a matching GitHub Release (e.g. tag v0.1.1) — the workflow builds and uploads it to PyPI.

[!NOTE] requires-python is >=3.14, so installs require Python 3.14+.

Documentation

The docs are built with MkDocs + Material and the API reference is generated automatically from docstrings via mkdocstrings.

uv run --group docs mkdocs serve          # live preview at http://127.0.0.1:8000
uv run --group docs mkdocs build --strict # production build into ./site

They deploy to GitHub Pages automatically on every push to main via .github/workflows/docs.yml. To enable publishing, set Settings → Pages → Build and deployment → Source to GitHub Actions in the repository once. Update the site_url/repo_url in mkdocs.yml if the repo lives under a different owner.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

linear_python_client-0.2.0.tar.gz (20.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

linear_python_client-0.2.0-py3-none-any.whl (24.6 kB view details)

Uploaded Python 3

File details

Details for the file linear_python_client-0.2.0.tar.gz.

File metadata

  • Download URL: linear_python_client-0.2.0.tar.gz
  • Upload date:
  • Size: 20.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for linear_python_client-0.2.0.tar.gz
Algorithm Hash digest
SHA256 fc38fc7e1733ec27b24d41a02f007840a0c5357ac6146bdca7e9de70c0c0265c
MD5 c5c691d38ecfbe2ece4fed96e48c9938
BLAKE2b-256 df76ea02e4f995ecdbdb650744eab2d507c1088a0f986ba3e26b75fe21e08ed3

See more details on using hashes here.

File details

Details for the file linear_python_client-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: linear_python_client-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 24.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for linear_python_client-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c1c8d852569c0c3c36c5bf33bc67764021826899d45343ba87f81e3a00bbd07f
MD5 bb52a6558fba68634351fad19a2076bd
BLAKE2b-256 2266d93eb7b87e16a210e4a90b5550cd1a68143c4bcd4fd028b8317618931e56

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page