Skip to main content

A modern, fully-typed Python client for the Jira Cloud REST API v3

Project description

pyJiraV3

A modern, fully-typed Python client for the Jira Cloud REST API v3.

Built on httpx and Pydantic v2. Supports both synchronous and asynchronous usage.

Requirements

  • Python 3.12+
  • A Jira Cloud instance with an API token

Installation

uv add pyjiraV3

Or install from source:

git clone https://github.com/faulander/pyJira.git
cd pyJira
uv sync

Quick Start

from pyjira import JiraClient

client = JiraClient(
  domain='mycompany',
  email='you@example.com',
  api_token='your-api-token',
)

issue = client.issues.get('PROJ-123')
print(issue.key, issue.fields.summary)

client.close()

Use as a context manager to close the underlying connection pool automatically:

with JiraClient(domain='mycompany', email='you@example.com', api_token='tok') as client:
  issue = client.issues.get('PROJ-123')

Async

import asyncio
from pyjira import AsyncJiraClient

async def main():
  async with AsyncJiraClient(
    domain='mycompany',
    email='you@example.com',
    api_token='your-api-token',
  ) as client:
    issue = await client.issues.get('PROJ-123')
    print(issue.key)

asyncio.run(main())

Every sync method has an identical async counterpart. The examples below show the sync API; prefix with await for async.

Authentication

pyJira uses Jira Cloud's Basic auth with API tokens. Generate a token at https://id.atlassian.com/manage/api-tokens.

# By Atlassian domain name (resolves to https://mycompany.atlassian.net)
client = JiraClient(domain='mycompany', email='...', api_token='...')

# By explicit base URL (for custom domains or proxies)
client = JiraClient(base_url='https://jira.internal.company.com', email='...', api_token='...')

You must provide either domain or base_url, not both.

Client Options

Parameter Type Default Description
domain str | None None Atlassian domain (e.g. 'mycompany')
base_url str | None None Full base URL (alternative to domain)
email str - Atlassian account email
api_token str - API token from Atlassian account settings
timeout float 30.0 Request timeout in seconds
headers dict | None None Additional HTTP headers to merge in

Resources

The client exposes five resource attributes, each mapping to a Jira API group:

client.issues     # Issue CRUD, transitions
client.projects   # Project listing and details
client.search     # JQL search with pagination
client.users      # User lookup
client.comments   # Issue comment CRUD

Issues

Get an issue

issue = client.issues.get('PROJ-123')

print(issue.key)                        # 'PROJ-123'
print(issue.fields.summary)             # 'Fix login bug'
print(issue.fields.status.name)         # 'In Progress'
print(issue.fields.assignee.display_name)  # 'Jane Doe'
print(issue.fields.priority.name)       # 'High'
print(issue.fields.labels)              # ['bug', 'auth']

Use fields to limit which fields are returned and expand to include additional data:

issue = client.issues.get(
  'PROJ-123',
  fields=['summary', 'status', 'assignee'],
  expand=['changelog', 'transitions'],
)

# Access expanded data
for history in issue.changelog.histories:
  for item in history.items:
    print(f'{item.field}: {item.from_string} -> {item.to_string}')

Create an issue

issue = client.issues.create(fields={
  'project': {'key': 'PROJ'},
  'summary': 'New bug report',
  'issuetype': {'name': 'Bug'},
  'description': {
    'type': 'doc',
    'version': 1,
    'content': [{
      'type': 'paragraph',
      'content': [{'type': 'text', 'text': 'Steps to reproduce...'}],
    }],
  },
  'priority': {'name': 'High'},
  'labels': ['bug', 'frontend'],
})

print(issue.key)  # 'PROJ-456'

Note: Jira API v3 uses Atlassian Document Format (ADF) for rich text fields like description and comment bodies.

Update an issue

# Update via fields
client.issues.update('PROJ-123', fields={
  'summary': 'Updated summary',
  'priority': {'name': 'Critical'},
})

# Update via the update operation (for array fields like labels)
client.issues.update('PROJ-123', update={
  'labels': [{'add': 'urgent'}, {'remove': 'low-priority'}],
})

# Silently update (no email notifications)
client.issues.update('PROJ-123', fields={'summary': 'Quiet fix'}, notify_users=False)

Delete an issue

client.issues.delete('PROJ-123')

# Also delete subtasks
client.issues.delete('PROJ-123', delete_subtasks=True)

Transitions

# List available transitions
transitions = client.issues.get_transitions('PROJ-123')
for t in transitions:
  print(f'{t.id}: {t.name} -> {t.to.name}')

# Transition an issue (e.g. move to "In Progress")
client.issues.transition('PROJ-123', transition_id='31')

# Transition with a comment
client.issues.transition('PROJ-123', '31', comment={
  'type': 'doc',
  'version': 1,
  'content': [{
    'type': 'paragraph',
    'content': [{'type': 'text', 'text': 'Starting work on this.'}],
  }],
})

Search (JQL)

Single-page search

results = client.search.jql('project = PROJ AND status = "In Progress"')

print(results.total)       # 42
print(results.start_at)    # 0
print(results.max_results) # 50

for issue in results.issues:
  print(issue.key, issue.fields.summary)

Control pagination, fields, and expansion:

results = client.search.jql(
  'assignee = currentUser() ORDER BY updated DESC',
  start_at=0,
  max_results=25,
  fields=['summary', 'status', 'updated'],
  expand=['changelog'],
)

Auto-paginated search

jql_paginated returns an iterator that transparently fetches pages as you consume results. No manual offset management needed:

# Sync - iterates through ALL matching issues
for issue in client.search.jql_paginated('project = PROJ', page_size=100):
  print(issue.key)

# Collect into a list
all_issues = list(client.search.jql_paginated('project = PROJ'))

Async:

async for issue in client.search.jql_paginated('project = PROJ'):
  print(issue.key)

Projects

# List all projects
projects = client.projects.list()
for p in projects:
  print(f'{p.key}: {p.name}')

# List with ordering
projects = client.projects.list(order_by='name')

# Get recently accessed projects
projects = client.projects.list(recent=5)

# Get a single project
project = client.projects.get('PROJ')
print(project.name)
print(project.lead.display_name)
print(project.project_type_key)  # 'software'

Users

# Get the authenticated user
me = client.users.myself()
print(me.display_name, me.email_address)

# Get a user by account ID
user = client.users.get('5b10ac8d82e05b22cc7d4ef5')
print(user.display_name)

# Search users
users = client.users.search(query='jane', max_results=10)
for u in users:
  print(u.display_name, u.account_id)

Comments

# List comments on an issue
page = client.comments.list('PROJ-123', max_results=20)
for comment in page.comments:
  print(f'{comment.author.display_name}: {comment.body}')
print(f'Showing {len(page.comments)} of {page.total}')

# Get a specific comment
comment = client.comments.get('PROJ-123', '10042')

# Add a comment (ADF body)
comment = client.comments.add('PROJ-123', body={
  'type': 'doc',
  'version': 1,
  'content': [{
    'type': 'paragraph',
    'content': [{'type': 'text', 'text': 'Looks good, merging now.'}],
  }],
})

# Add a restricted comment (visible only to a role)
comment = client.comments.add(
  'PROJ-123',
  body={'type': 'doc', 'version': 1, 'content': [{'type': 'paragraph', 'content': [{'type': 'text', 'text': 'Internal note.'}]}]},
  visibility={'type': 'role', 'value': 'Developers'},
)

# Update a comment
client.comments.update('PROJ-123', '10042', body={
  'type': 'doc',
  'version': 1,
  'content': [{
    'type': 'paragraph',
    'content': [{'type': 'text', 'text': 'Updated comment text.'}],
  }],
})

# Delete a comment
client.comments.delete('PROJ-123', '10042')

Expand Parameter

The Jira API uses resource expansion to keep responses compact. Pass expand as a list of strings to include additional data:

# Expand changelog and transitions on an issue
issue = client.issues.get('PROJ-123', expand=['changelog', 'transitions'])

# Expand in search results
results = client.search.jql('project = PROJ', expand=['changelog', 'names'])

Error Handling

All API errors raise typed exceptions with structured error data from the Jira response:

from pyjira import (
  JiraError,
  AuthenticationError,
  ForbiddenError,
  NotFoundError,
  ValidationError,
  RateLimitError,
  ServerError,
)

try:
  client.issues.get('INVALID-999')
except NotFoundError as e:
  print(e.status_code)      # 404
  print(e.error_messages)   # ['Issue does not exist or you do not have permission to see it.']
  print(e.errors)           # {}
  print(e.response)         # httpx.Response (for inspection)
except AuthenticationError:
  print('Check your email/token')
except RateLimitError as e:
  print(f'Rate limited. Retry after {e.retry_after} seconds')
except JiraError as e:
  print(f'Unexpected Jira error: {e}')

Exception Hierarchy

Exception HTTP Status When
JiraError any Base class for all Jira API errors
ValidationError 400 Bad request / invalid fields
AuthenticationError 401 Invalid or missing credentials
ForbiddenError 403 Insufficient permissions
NotFoundError 404 Resource does not exist
RateLimitError 429 Too many requests (has retry_after)
ServerError 5xx Jira server error

Every exception carries:

  • status_code: int | None - HTTP status code
  • error_messages: list[str] - Human-readable error messages from Jira
  • errors: dict[str, str] - Field-level errors (e.g. {'summary': 'Field is required'})
  • response: httpx.Response | None - Raw response for advanced inspection

Models

All API responses are deserialized into Pydantic v2 models with full type annotations. Models use extra='allow' so unknown fields from the API are preserved rather than dropped.

Key Models

Model Description
Issue Jira issue with key, id, fields, changelog
IssueFields Typed issue fields (summary, status, assignee..)
Project Project with key, name, lead, type
User User with account_id, display_name, email
Comment Issue comment with author, body, timestamps
SearchResults Paginated search response with issues list
Status Issue status with category
Priority Issue priority
IssueType Issue type (Bug, Task, Story...)
Transition Workflow transition with target status
Resolution Issue resolution (Fixed, Won't Fix...)
Changelog Issue change history
ChangeHistory Single changelog entry with author and items
ChangeItem Individual field change (from -> to)

Field Access

Jira's API returns camelCase JSON. Pydantic aliases map these to snake_case Python attributes:

issue = client.issues.get('PROJ-123')

# Python attributes are snake_case
issue.fields.status_category    # not statusCategory
issue.fields.issue_type         # not issueType
issue.fields.fix_versions       # not fixVersions

user = client.users.myself()
user.display_name               # not displayName
user.email_address              # not emailAddress
user.account_id                 # not accountId

Working with Raw Data

Since models use extra='allow', any field the API returns that isn't explicitly modeled is still accessible:

issue = client.issues.get('PROJ-123')

# Access custom fields or unmodeled fields via model_extra
raw = issue.fields.model_extra
custom_field_value = raw.get('customfield_10042')

Pagination

The Jira API paginates large result sets using startAt / maxResults / total.

Manual Pagination

start = 0
page_size = 100
while True:
  results = client.search.jql('project = PROJ', start_at=start, max_results=page_size)
  for issue in results.issues:
    process(issue)
  start += len(results.issues)
  if start >= results.total:
    break

Auto Pagination

The jql_paginated method handles this for you, yielding issues one at a time across pages:

for issue in client.search.jql_paginated('project = PROJ', page_size=100):
  process(issue)

Under the hood, Paginator and AsyncPaginator are generic iterators you can use for any paginated operation:

from pyjira.pagination import Paginator

def fetch_page(start_at, max_results):
  results = client.search.jql('...', start_at=start_at, max_results=max_results)
  return results.issues, results.total

for issue in Paginator(fetch_page, page_size=50):
  print(issue.key)

Project Structure

src/pyjira/
  __init__.py          # Public API exports
  client.py            # JiraClient, AsyncJiraClient
  auth.py              # httpx.BasicAuth wrapper
  config.py            # JiraConfig dataclass
  exceptions.py        # Exception hierarchy + raise_for_response()
  pagination.py        # Paginator, AsyncPaginator
  models/
    __init__.py        # Model re-exports
    common.py          # JiraModel base, AvatarUrls, PaginatedResponse
    errors.py          # ErrorResponse
    issue.py           # Issue, IssueFields, Status, Priority, Transition, Changelog...
    project.py         # Project, ProjectCategory
    user.py            # User
    comment.py         # Comment, Visibility
    search.py          # SearchResults
  resources/
    __init__.py        # Resource re-exports
    issues.py          # IssueResource, AsyncIssueResource
    projects.py        # ProjectResource, AsyncProjectResource
    search.py          # SearchResource, AsyncSearchResource
    users.py           # UserResource, AsyncUserResource
    comments.py        # CommentResource, AsyncCommentResource

Development

Setup

git clone https://github.com/faulander/pyJira.git
cd pyJira
uv sync --all-extras

Run Tests

uv run pytest
uv run pytest -v          # verbose
uv run pytest -x          # stop on first failure
uv run pytest -k search   # run only search tests

Tests use respx to mock httpx requests. No real Jira instance is needed.

Dependencies

Runtime:

  • httpx >= 0.28 - HTTP client with sync and async support
  • pydantic >= 2.0 - Data validation and typed models

Dev:

  • pytest >= 8.0 - Test framework
  • pytest-asyncio >= 0.24 - Async test support
  • respx >= 0.22 - httpx request mocking

License

MIT

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

pyjirav3-0.1.2.tar.gz (946.0 kB view details)

Uploaded Source

Built Distribution

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

pyjirav3-0.1.2-py3-none-any.whl (48.1 kB view details)

Uploaded Python 3

File details

Details for the file pyjirav3-0.1.2.tar.gz.

File metadata

  • Download URL: pyjirav3-0.1.2.tar.gz
  • Upload date:
  • Size: 946.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyjirav3-0.1.2.tar.gz
Algorithm Hash digest
SHA256 891bb584daf2b19611d41a56c071a21d7acef5c02419d031210fa550796b13c6
MD5 5df47d3527c13efc08d25480b81413c6
BLAKE2b-256 41fe5cf6c55ecf18d278aee106153fa2918588a9a24cdee3d75aeb21e4e6de2a

See more details on using hashes here.

File details

Details for the file pyjirav3-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: pyjirav3-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 48.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyjirav3-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 ac668570c2db630cb6974803c1c0f6d4ba59a1dc90be7a53521b6c5394c050ed
MD5 652f175e6ba5c27f966d5cbf9907e9b2
BLAKE2b-256 69a4d18d5ce5882c31174865ea16992dea2fd7108e953f112e17cbcf1465647e

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