Coverage for presidio_analyzer / predefined_recognizers / country_specific / india / in_gstin_recognizer.py: 91%

54 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-29 09:03 +0000

1from typing import List, Optional, Tuple 

2 

3from presidio_analyzer import Pattern, PatternRecognizer 

4 

5 

6class InGstinRecognizer(PatternRecognizer): 

7 """ 

8 Recognizes Indian Goods and Services Tax Identification Number ("GSTIN"). 

9 

10 The GSTIN is a 15-character identifier with the following structure: 

11 - First 2 digits: State code (01-37) 

12 - Next 10 digits: PAN of the entity 

13 - 13th digit: Registration number for same PAN in the state 

14 - 14th digit: 'Z' 

15 - 15th digit: Checksum 

16 

17 Reference: https://www.gst.gov.in/ 

18 This recognizer identifies GSTIN using regex and context words. 

19 

20 :param patterns: List of patterns to be used by this recognizer 

21 :param context: List of context words to increase confidence in detection 

22 :param supported_language: Language this recognizer supports 

23 :param supported_entity: The entity this recognizer can detect 

24 :param replacement_pairs: List of tuples with potential replacement values 

25 for different strings to be used during pattern matching. 

26 This can allow a greater variety in input, for example by removing dashes or spaces. 

27 """ 

28 

29 PATTERNS = [ 

30 Pattern( 

31 "GSTIN (High)", 

32 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z0-9]{10}[A-Za-z0-9]{1}Z[A-Za-z0-9]{1})\b", 

33 0.8, 

34 ), 

35 Pattern( 

36 "GSTIN (Medium)", 

37 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z0-9]{11}Z[A-Za-z0-9]{1})\b", 

38 0.4, 

39 ), 

40 Pattern( 

41 "GSTIN (Low)", 

42 r"\b([0-9]{2}[A-Za-z0-9]{11}Z[A-Za-z0-9]{1})\b", 

43 0.1, 

44 ), 

45 ] 

46 

47 CONTEXT = [ 

48 "gstin", 

49 "gst", 

50 "goods and services tax", 

51 "tax identification", 

52 "gst number", 

53 "gst registration", 

54 ] 

55 

56 def __init__( 

57 self, 

58 patterns: Optional[List[Pattern]] = None, 

59 context: Optional[List[str]] = None, 

60 supported_language: str = "en", 

61 supported_entity: str = "IN_GSTIN", 

62 replacement_pairs: Optional[List[Tuple[str, str]]] = None, 

63 name: Optional[str] = None, 

64 ): 

65 self.replacement_pairs = ( 

66 replacement_pairs if replacement_pairs else [("-", ""), (" ", "")] 

67 ) 

68 patterns = patterns if patterns else self.PATTERNS 

69 context = context if context else self.CONTEXT 

70 super().__init__( 

71 supported_entity=supported_entity, 

72 patterns=patterns, 

73 context=context, 

74 supported_language=supported_language, 

75 name=name, 

76 ) 

77 self.supported_entity = supported_entity 

78 

79 def validate_result(self, pattern_text: str) -> bool: 

80 """ 

81 Validate the GSTIN format and structure. 

82 

83 :param pattern_text: The text pattern to validate 

84 :return: True if the GSTIN is valid, False otherwise 

85 """ 

86 sanitized_value = self._sanitize_value(pattern_text) 

87 return self._validate_gstin(sanitized_value) 

88 

89 def _sanitize_value(self, text: str) -> str: 

90 """Remove common separators and normalize the text.""" 

91 import re 

92 

93 # First, try to extract GSTIN pattern from the text 

94 gstin_pattern = ( 

95 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z]{5}[0-9]{4}[A-Za-z]{1}" 

96 r"[0-9A-Za-z]{1}Z[0-9A-Za-z]{1})\b" 

97 ) 

98 match = re.search(gstin_pattern, text.upper()) 

99 if match: 

100 return match.group(1) 

101 

102 # If no GSTIN pattern found, sanitize the entire text 

103 sanitized = text.upper() 

104 for old, new in self.replacement_pairs: 

105 sanitized = sanitized.replace(old, new) 

106 return sanitized 

107 

108 def _validate_gstin(self, gstin: str) -> bool: 

109 """ 

110 Validate GSTIN structure and format. 

111 

112 :param gstin: The GSTIN string to validate 

113 :return: True if valid, False otherwise 

114 """ 

115 if len(gstin) != 15: 

116 return False 

117 

118 # Check state code (first 2 digits should be 01-37) 

119 state_code = gstin[:2] 

120 if not state_code.isdigit() or not (1 <= int(state_code) <= 37): 

121 return False 

122 

123 # Check PAN format (characters 3-12) 

124 pan_part = gstin[2:12] 

125 if not self._validate_pan_format(pan_part): 

126 return False 

127 

128 # Check 13th character (registration number) 

129 reg_number = gstin[12] 

130 if not reg_number.isalnum(): 

131 return False 

132 

133 # Check 14th character should be 'Z' 

134 if gstin[13] != "Z": 

135 return False 

136 

137 # Check 15th character (checksum) 

138 checksum = gstin[14] 

139 if not checksum.isalnum(): 

140 return False 

141 

142 return True 

143 

144 def _validate_pan_format(self, pan: str) -> bool: 

145 """ 

146 Validate PAN format within GSTIN. 

147 

148 PAN format: 5 letters + 4 digits + 1 letter 

149 However, some valid PANs may have digits in the first 5 characters. 

150 

151 :param pan: The PAN part of GSTIN (10 characters) 

152 :return: True if valid PAN format, False otherwise 

153 """ 

154 if len(pan) != 10: 

155 return False 

156 

157 # Check that it contains a mix of letters and digits 

158 # At least 3 letters in the first 5 characters 

159 first_five = pan[:5] 

160 letter_count = sum(1 for c in first_five if c.isalpha()) 

161 if letter_count < 3: 

162 return False 

163 

164 # Characters 6-9 should be digits 

165 if not pan[5:9].isdigit(): 

166 return False 

167 

168 # 10th character should be a letter 

169 if not pan[9].isalpha(): 

170 return False 

171 

172 return True