Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jira/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,9 @@ def __init__(

self._fields_cache_value: dict[str, str] = {} # access via self._fields_cache

# Store fields cache reference on session for display name field injection
self._session.fields_cache = self._fields_cache
Comment on lines +677 to +678
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a hack to me, but I could not find a way to access _fields_cache from resources directly, but I would love to be pointed in the 'right' way to do it.


@property
def _fields_cache(self) -> dict[str, str]:
"""Cached dictionary of {Field Name: Field ID}. Lazy loaded."""
Expand Down
1 change: 1 addition & 0 deletions jira/resilientsession.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def __init__(self, timeout=None, max_retries: int = 3, max_retry_delay: int = 60
self.timeout = timeout
self.max_retries = max_retries
self.max_retry_delay = max_retry_delay
self.fields_cache: dict[str, str] = {}
super().__init__()

# Indicate our preference for JSON to avoid https://bitbucket.org/bspeakmon/jira-python/issue/46 and https://jira.atlassian.com/browse/JRA-38551
Expand Down
36 changes: 36 additions & 0 deletions jira/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1681,8 +1681,44 @@ def dict2resource(
setattr(top, i, seq_list)
else:
setattr(top, i, j)

if session and hasattr(session, 'fields_cache') and session.fields_cache:
_add_display_name_fields(top, session)

return top

def convert_display_name_to_python_name(display_name: str) -> str:
"""Convert JIRA field display name to Python attribute name.

Args:
display_name: JIRA field display name (e.g., "Epic Link", "Story Points")

Returns:
Python-compatible attribute name (e.g., "epic_link", "story_points")
"""
python_name = re.sub(r'[^a-zA-Z0-9_]', '_', display_name.lower())
python_name = re.sub(r'_+', '_', python_name).strip('_')
if python_name and python_name[0].isdigit():
python_name = 'field_' + python_name
return python_name

def _add_display_name_fields(obj: PropertyHolder, session) -> None:
"""Create readable field name aliases for JIRA custom fields.

Adds attributes like 'epic_link' alongside 'customfield_10001'
"""
custom_fields = [attr for attr in dir(obj) if attr.startswith('customfield_')]
if not custom_fields:
return

for display_name, field_id in session.fields_cache.items():
if field_id in set(custom_fields):
python_name = convert_display_name_to_python_name(display_name)

if not hasattr(obj, python_name):
field_value = getattr(obj, field_id)
setattr(obj, python_name, field_value)


resource_class_map: dict[str, type[Resource]] = {
# Jira-specific resources
Expand Down
161 changes: 161 additions & 0 deletions tests/resources/test_issue_display_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from __future__ import annotations

from jira.resources import convert_display_name_to_python_name
from tests.conftest import JiraTestCase


class IssueDisplayNameFieldsTest(JiraTestCase):
def setUp(self) -> None:
super().setUp()
self.issue_1 = self.test_manager.project_b_issue1
self.issue_1_obj = self.test_manager.project_b_issue1_obj

def test_issue_has_display_name_fields(self):
issue = self.issue_1_obj
all_attrs = [attr for attr in dir(issue.fields) if not attr.startswith('__')]
custom_field_ids = [attr for attr in all_attrs if attr.startswith('customfield_')]

self.assertGreater(len(custom_field_ids), 0)

standard_fields = ['summary', 'status', 'priority', 'created']
for field in standard_fields:
self.assertIn(field, all_attrs)

expected_minimum = len(custom_field_ids) + len(standard_fields)
self.assertGreater(len(all_attrs), expected_minimum)

def test_issue_field_access_patterns(self):
issue = self.issue_1_obj

self.assertIsNotNone(issue.fields.summary)
self.assertIsNotNone(issue.fields.status)

custom_fields = [attr for attr in dir(issue.fields) if attr.startswith('customfield_')]
if custom_fields:
getattr(issue.fields, custom_fields[0])

all_fields = dir(issue.fields)
self.assertIsInstance(all_fields, list)
self.assertGreater(len(all_fields), 10)

def test_issue_field_equivalence_real_data(self):
issue = self.issue_1_obj

if not hasattr(self.jira, '_fields_cache') or not self.jira._fields_cache:
self.skipTest("JIRA instance doesn't have fields cache populated")

fields_cache = self.jira._fields_cache
tested_pairs = 0

for display_name, field_id in fields_cache.items():
if tested_pairs >= 3:
break

if hasattr(issue.fields, field_id):
python_name = convert_display_name_to_python_name(display_name)

if hasattr(issue.fields, python_name):
original_value = getattr(issue.fields, field_id)
display_value = getattr(issue.fields, python_name)
self.assertEqual(original_value, display_value)
tested_pairs += 1

if tested_pairs == 0:
self.skipTest("No suitable field pairs found for equivalence testing")

def test_issue_custom_field_values_preserved(self):
issue = self.issue_1_obj

custom_fields_with_values = []
for attr in dir(issue.fields):
if attr.startswith('customfield_'):
value = getattr(issue.fields, attr, None)
if value is not None:
custom_fields_with_values.append((attr, value))

self.assertGreater(len(custom_fields_with_values), 0)

fields_cache = getattr(self.jira, '_fields_cache', {})

for field_id, original_value in custom_fields_with_values[:3]:
display_name = None
for name, fid in fields_cache.items():
if fid == field_id:
display_name = name
break

if display_name:
python_name = convert_display_name_to_python_name(display_name)

if hasattr(issue.fields, python_name):
display_value = getattr(issue.fields, python_name)
self.assertIs(
original_value, display_value,
f"Values should be the same object: {field_id} vs {python_name}"
)

def test_issue_fields_dir_includes_display_names(self):
issue = self.issue_1_obj
all_attrs = dir(issue.fields)

standard_fields = ['summary', 'status', 'priority', 'issuetype']
for field in standard_fields:
self.assertIn(field, all_attrs)

custom_field_ids = [attr for attr in all_attrs if attr.startswith('customfield_')]
self.assertGreater(len(custom_field_ids), 0)

standard_and_custom = set(standard_fields + custom_field_ids +
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
'__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__', '_issue_session',
'aggregateprogress', 'aggregatetimeestimate', 'aggregatetimeoriginalestimate',
'aggregatetimespent', 'archivedby', 'archiveddate', 'assignee', 'attachment',
'comment', 'components', 'created', 'creator', 'description', 'duedate',
'environment', 'fixVersions', 'issuelinks', 'labels', 'lastViewed',
'progress', 'project', 'reporter', 'resolution', 'resolutiondate',
'security', 'subtasks', 'timeestimate', 'timeoriginalestimate',
'timespent', 'timetracking', 'updated', 'versions', 'votes',
'watches', 'worklog', 'workratio'])

display_name_fields = [attr for attr in all_attrs if attr not in standard_and_custom]
self.assertGreater(len(display_name_fields), 0)

def test_issue_creation_with_display_names(self):
fresh_issue = self.jira.issue(self.issue_1)

all_attrs = dir(fresh_issue.fields)
custom_fields = [attr for attr in all_attrs if attr.startswith('customfield_')]

expected_display_names = len(custom_fields) > 0

if expected_display_names:
standard_and_meta_fields = {
'__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
'__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__', '_issue_session',
'aggregateprogress', 'aggregatetimeestimate', 'aggregatetimeoriginalestimate',
'aggregatetimespent', 'archivedby', 'archiveddate', 'assignee', 'attachment',
'comment', 'components', 'created', 'creator', 'description', 'duedate',
'environment', 'fixVersions', 'issuelinks', 'issuetype', 'labels', 'lastViewed',
'priority', 'progress', 'project', 'reporter', 'resolution', 'resolutiondate',
'security', 'status', 'subtasks', 'summary', 'timeestimate',
'timeoriginalestimate', 'timespent', 'timetracking', 'updated',
'versions', 'votes', 'watches', 'worklog', 'workratio'
}

potential_display_names = [
attr for attr in all_attrs
if attr not in standard_and_meta_fields and not attr.startswith('customfield_')
]

self.assertGreater(len(potential_display_names), 0)


if __name__ == '__main__':
import unittest
unittest.main()
Loading
Loading