Coverage for src / rendercv / schema / models / cv / social_network.py: 100%

66 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1import functools 

2import re 

3from typing import Literal, get_args 

4 

5import pydantic 

6import pydantic_core 

7import pydantic_extra_types.phone_numbers as pydantic_phone_numbers 

8 

9from ...pydantic_error_handling import CustomPydanticErrorTypes 

10from ..base import BaseModelWithoutExtraKeys 

11 

12url_validator = pydantic.TypeAdapter[pydantic.HttpUrl](pydantic.HttpUrl) 

13 

14 

15type SocialNetworkName = Literal[ 

16 "LinkedIn", 

17 "GitHub", 

18 "GitLab", 

19 "IMDB", 

20 "Instagram", 

21 "ORCID", 

22 "Mastodon", 

23 "StackOverflow", 

24 "ResearchGate", 

25 "YouTube", 

26 "Google Scholar", 

27 "Telegram", 

28 "WhatsApp", 

29 "Leetcode", 

30 "X", 

31 "Bluesky", 

32 "Reddit", 

33] 

34available_social_networks = get_args(SocialNetworkName.__value__) 

35url_dictionary: dict[SocialNetworkName, str] = { 

36 "LinkedIn": "https://linkedin.com/in/", 

37 "GitHub": "https://github.com/", 

38 "GitLab": "https://gitlab.com/", 

39 "IMDB": "https://imdb.com/name/", 

40 "Instagram": "https://instagram.com/", 

41 "ORCID": "https://orcid.org/", 

42 "StackOverflow": "https://stackoverflow.com/users/", 

43 "ResearchGate": "https://researchgate.net/profile/", 

44 "YouTube": "https://youtube.com/@", 

45 "Google Scholar": "https://scholar.google.com/citations?user=", 

46 "Telegram": "https://t.me/", 

47 "WhatsApp": "https://wa.me/", 

48 "Leetcode": "https://leetcode.com/u/", 

49 "X": "https://x.com/", 

50 "Bluesky": "https://bsky.app/profile/", 

51 "Reddit": "https://reddit.com/user/", 

52} 

53 

54 

55class SocialNetwork(BaseModelWithoutExtraKeys): 

56 network: SocialNetworkName = pydantic.Field() 

57 username: str = pydantic.Field( 

58 examples=["john_doe", "@johndoe@mastodon.social", "12345/john-doe"], 

59 ) 

60 

61 @pydantic.field_validator("username") 

62 @classmethod 

63 def check_username(cls, username: str, info: pydantic.ValidationInfo) -> str: 

64 """Validate username format per network's requirements. 

65 

66 Why: 

67 Different platforms have specific username formats (e.g., Mastodon needs 

68 @user@domain, StackOverflow needs id/name). Early validation prevents 

69 broken URL generation. 

70 

71 Args: 

72 username: Username to validate. 

73 info: Validation context containing network field. 

74 

75 Returns: 

76 Validated username. 

77 """ 

78 if "network" not in info.data: 

79 # the network is either not provided or not one of the available social 

80 # networks. In this case, don't check the username, since Pydantic will 

81 # raise an error for the network. 

82 return username 

83 

84 network = info.data["network"] 

85 

86 match network: 

87 case "Mastodon": 

88 mastodon_username_pattern = r"@[^@]+@[^@]+" 

89 if not re.fullmatch(mastodon_username_pattern, username): 

90 raise pydantic_core.PydanticCustomError( 

91 CustomPydanticErrorTypes.other.value, 

92 'Mastodon username should be in the format "@username@domain".', 

93 ) 

94 case "StackOverflow": 

95 stackoverflow_username_pattern = r"\d+\/[^\/]+" 

96 if not re.fullmatch(stackoverflow_username_pattern, username): 

97 raise pydantic_core.PydanticCustomError( 

98 CustomPydanticErrorTypes.other.value, 

99 "StackOverflow username should be in the format" 

100 ' "user_id/username".', 

101 ) 

102 case "YouTube": 

103 if username.startswith("@"): 

104 raise pydantic_core.PydanticCustomError( 

105 CustomPydanticErrorTypes.other.value, 

106 'YouTube username should not start with "@". Remove "@" from' 

107 ' the beginning of the username."', 

108 ) 

109 case "ORCID": 

110 orcid_username_pattern = r"\d{4}-\d{4}-\d{4}-\d{3}[\dX]" 

111 if not re.fullmatch(orcid_username_pattern, username): 

112 raise pydantic_core.PydanticCustomError( 

113 CustomPydanticErrorTypes.other.value, 

114 "ORCID username should be in the format 'XXXX-XXXX-XXXX-XXX'.", 

115 ) 

116 case "IMDB": 

117 imdb_username_pattern = r"nm\d{7}" 

118 if not re.fullmatch(imdb_username_pattern, username): 

119 raise pydantic_core.PydanticCustomError( 

120 CustomPydanticErrorTypes.other.value, 

121 "IMDB name should be in the format 'nmXXXXXXX'.", 

122 ) 

123 case "Bluesky": 

124 bluesky_username_pattern = r"^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$" 

125 if not re.fullmatch(bluesky_username_pattern, username): 

126 raise pydantic_core.PydanticCustomError( 

127 CustomPydanticErrorTypes.other.value, 

128 "Bluesky username should be a valid handle with no '@' (e.g.," 

129 " 'username.bsky.social' or 'domain.com').", 

130 ) 

131 case "WhatsApp": 

132 try: 

133 pydantic.TypeAdapter[pydantic_phone_numbers.PhoneNumber]( 

134 pydantic_phone_numbers.PhoneNumber 

135 ).validate_python(username) 

136 except pydantic.ValidationError as e: 

137 raise pydantic_core.PydanticCustomError( 

138 CustomPydanticErrorTypes.other.value, 

139 "WhatsApp username should be your phone number with country" 

140 " code in international format (e.g., +1 for USA, +44 for UK).", 

141 ) from e 

142 case "Reddit": 

143 reddit_username_pattern = r"^[a-zA-Z0-9_-]{3,23}$" 

144 if not re.fullmatch(reddit_username_pattern, username): 

145 raise pydantic_core.PydanticCustomError( 

146 CustomPydanticErrorTypes.other.value, 

147 "Reddit username should be made up of uppercase/lowercase" 

148 " letters, numbers, underscores, and hyphens between 3 and 23" 

149 " characters.", 

150 ) 

151 

152 return username 

153 

154 @pydantic.model_validator(mode="after") 

155 def validate_generated_url(self) -> "SocialNetwork": 

156 """Validate generated URL is well-formed. 

157 

158 Why: 

159 URL generation from username might produce invalid URLs if username 

160 format is wrong. Post-validation check catches edge cases. 

161 

162 Returns: 

163 Validated social network instance. 

164 """ 

165 url_validator.validate_strings(self.url) 

166 return self 

167 

168 @functools.cached_property 

169 def url(self) -> str: 

170 """Generate profile URL from network and username. 

171 

172 Why: 

173 Users provide network+username for brevity. Property generates full 

174 URLs with platform-specific logic (e.g., Mastodon domain extraction). 

175 

176 Returns: 

177 Complete profile URL. 

178 """ 

179 if self.network == "Mastodon": 

180 _, username, domain = self.username.split("@") 

181 url = f"https://{domain}/@{username}" 

182 else: 

183 url = url_dictionary[self.network] + self.username 

184 

185 return url