diff --git a/docs/examples.rst b/docs/examples.rst index d4e6e1cfa..6de22e5a5 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -291,6 +291,28 @@ content needs to be formatted using the Atlassian Document Format (ADF):: Fields ------ +Custom Field Display Name Mapping +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Access custom fields using display names instead of ``customfield_XXXX`` IDs:: + + # Both return the same value + issue.fields.customfield_10001 # traditional + issue.fields.story_points # display name + + # Example usage + if hasattr(issue.fields, 'story_points'): + print(f"Points: {issue.fields.story_points}") + +Field names are converted to Python identifiers:: + + # "Story Points" -> story_points + # "Epic Link" -> epic_link + # "3rd Party" -> field_3rd_party + +.. note:: + Display name fields won't overwrite existing attributes. + Example for accessing the worklogs:: issue.fields.worklog.worklogs # list of Worklog objects diff --git a/jira/client.py b/jira/client.py index b23aff2de..2328774c5 100644 --- a/jira/client.py +++ b/jira/client.py @@ -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 + @property def _fields_cache(self) -> dict[str, str]: """Cached dictionary of {Field Name: Field ID}. Lazy loaded.""" diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 39ce7643d..c551795c4 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -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 diff --git a/jira/resources.py b/jira/resources.py index 7127e2402..c6ac2c917 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -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., "Story Points", "Sprint") + + Returns: + Python-compatible attribute name (e.g., "story_points", "sprint") + """ + 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 'story_points' 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 diff --git a/tests/resources/test_issue_display_names.py b/tests/resources/test_issue_display_names.py new file mode 100644 index 000000000..07d48aef2 --- /dev/null +++ b/tests/resources/test_issue_display_names.py @@ -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() diff --git a/tests/test_display_name_fields.py b/tests/test_display_name_fields.py new file mode 100644 index 000000000..2f8bfd79d --- /dev/null +++ b/tests/test_display_name_fields.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from jira.resources import ( + PropertyHolder, + _add_display_name_fields, + convert_display_name_to_python_name, +) +from tests.conftest import JiraTestCase + + +class DisplayNameFieldConversionTest(unittest.TestCase): + def test_basic_field_name_conversion(self): + self.assertEqual(convert_display_name_to_python_name("Story Points"), "story_points") + self.assertEqual(convert_display_name_to_python_name("Internal Target Milestone"), "internal_target_milestone") + self.assertEqual(convert_display_name_to_python_name("Epic Link"), "epic_link") + + def test_special_character_handling(self): + self.assertEqual(convert_display_name_to_python_name("Story-Points"), "story_points") + self.assertEqual(convert_display_name_to_python_name("Business Value---Score"), "business_value_score") + self.assertEqual(convert_display_name_to_python_name("Field!!Name@@Here"), "field_name_here") + self.assertEqual(convert_display_name_to_python_name("-Story Points-"), "story_points") + self.assertEqual(convert_display_name_to_python_name("__Field Name__"), "field_name") + + def test_numeric_field_names(self): + self.assertEqual(convert_display_name_to_python_name("10 Point Scale"), "field_10_point_scale") + self.assertEqual(convert_display_name_to_python_name("3rd Party Integration"), "field_3rd_party_integration") + self.assertEqual(convert_display_name_to_python_name("2023 Budget"), "field_2023_budget") + + def test_edge_cases(self): + self.assertEqual(convert_display_name_to_python_name("A"), "a") + self.assertEqual(convert_display_name_to_python_name("1"), "field_1") + self.assertEqual(convert_display_name_to_python_name("story_points"), "story_points") + self.assertEqual(convert_display_name_to_python_name("STORY_POINTS"), "story_points") + self.assertEqual(convert_display_name_to_python_name("CamelCaseField"), "camelcasefield") + + +class DisplayNameFieldIntegrationTest(JiraTestCase): + def setUp(self): + super().setUp() + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_1_obj = self.test_manager.project_b_issue1_obj + + def test_display_name_field_creation(self): + issue = self.issue_1_obj + all_fields = dir(issue.fields) + custom_field_ids = [f for f in all_fields if f.startswith('customfield_')] + + self.assertGreater(len(custom_field_ids), 0) + total_fields = len([f for f in all_fields if not f.startswith('__')]) + self.assertGreater(total_fields, len(custom_field_ids) + 20) + + def test_field_equivalence(self): + issue = self.issue_1_obj + + if hasattr(self.jira, '_fields_cache') and self.jira._fields_cache: + fields_cache = self.jira._fields_cache + tested_equivalence = False + + for display_name, field_id in list(fields_cache.items())[:5]: + 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_equivalence = True + break + + if not tested_equivalence: + self.skipTest("No suitable fields found for equivalence testing") + + def test_backwards_compatibility(self): + issue = self.issue_1_obj + + standard_fields = ['summary', 'status', 'priority', 'created', 'updated'] + for field_name in standard_fields: + self.assertTrue(hasattr(issue.fields, field_name)) + value = getattr(issue.fields, field_name) + self.assertIsNotNone(value) + + custom_fields = [attr for attr in dir(issue.fields) if attr.startswith('customfield_')] + self.assertGreater(len(custom_fields), 0) + + for field_id in custom_fields[:3]: + getattr(issue.fields, field_id) + + +class DisplayNameFieldMockTest(unittest.TestCase): + def _create_mock_property_holder(self, field_data: dict) -> PropertyHolder: + obj = PropertyHolder() + for field_name, field_value in field_data.items(): + setattr(obj, field_name, field_value) + return obj + + def _create_mock_session(self, fields_cache: dict) -> MagicMock: + session = MagicMock() + session.fields_cache = fields_cache + return session + + def test_display_name_creation_with_mock_data(self): + mock_fields = { + 'customfield_10001': 5, + 'customfield_10002': 42, + 'customfield_10003': ['label1', 'label2'], + 'summary': 'Test Issue' + } + + mock_cache = { + 'Story Points': 'customfield_10001', + 'Sprint': 'customfield_10002', + 'Labels': 'customfield_10003' + } + + obj = self._create_mock_property_holder(mock_fields) + session = self._create_mock_session(mock_cache) + + _add_display_name_fields(obj, session) + + self.assertTrue(hasattr(obj, 'story_points')) + self.assertTrue(hasattr(obj, 'sprint')) + self.assertTrue(hasattr(obj, 'labels')) + + self.assertEqual(obj.story_points, 5) + self.assertEqual(obj.sprint, 42) + self.assertEqual(obj.labels, ['label1', 'label2']) + + self.assertEqual(obj.customfield_10001, 5) + self.assertEqual(obj.customfield_10002, 42) + self.assertEqual(obj.customfield_10003, ['label1', 'label2']) + + def test_no_custom_fields(self): + obj = self._create_mock_property_holder({ + 'summary': 'Test Issue', + 'status': 'Open', + 'priority': 'High' + }) + session = self._create_mock_session({'Story Points': 'customfield_10001'}) + + initial_attrs = set(dir(obj)) + _add_display_name_fields(obj, session) + final_attrs = set(dir(obj)) + + self.assertEqual(initial_attrs, final_attrs) + + def test_name_collision_prevention(self): + obj = self._create_mock_property_holder({ + 'customfield_10001': 'epic-value', + 'summary': 'Original Summary' + }) + + session = self._create_mock_session({ + 'Summary': 'customfield_10001' + }) + + _add_display_name_fields(obj, session) + + self.assertEqual(obj.summary, 'Original Summary') + self.assertNotEqual(obj.summary, 'epic-value') + + def test_none_and_empty_values(self): + obj = self._create_mock_property_holder({ + 'customfield_10001': None, + 'customfield_10002': '', + 'customfield_10003': [] + }) + session = self._create_mock_session({ + 'Story Points': 'customfield_10001', + 'Summary': 'customfield_10002', + 'Labels': 'customfield_10003' + }) + + _add_display_name_fields(obj, session) + + self.assertTrue(hasattr(obj, 'story_points')) + self.assertTrue(hasattr(obj, 'summary')) + self.assertTrue(hasattr(obj, 'labels')) + + self.assertIsNone(obj.story_points) + self.assertEqual(obj.summary, '') + self.assertEqual(obj.labels, []) + + +if __name__ == '__main__': + unittest.main()