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
« 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
5import pydantic
6import pydantic_core
7import pydantic_extra_types.phone_numbers as pydantic_phone_numbers
9from ...pydantic_error_handling import CustomPydanticErrorTypes
10from ..base import BaseModelWithoutExtraKeys
12url_validator = pydantic.TypeAdapter[pydantic.HttpUrl](pydantic.HttpUrl)
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}
55class SocialNetwork(BaseModelWithoutExtraKeys):
56 network: SocialNetworkName = pydantic.Field()
57 username: str = pydantic.Field(
58 examples=["john_doe", "@johndoe@mastodon.social", "12345/john-doe"],
59 )
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.
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.
71 Args:
72 username: Username to validate.
73 info: Validation context containing network field.
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
84 network = info.data["network"]
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 )
152 return username
154 @pydantic.model_validator(mode="after")
155 def validate_generated_url(self) -> "SocialNetwork":
156 """Validate generated URL is well-formed.
158 Why:
159 URL generation from username might produce invalid URLs if username
160 format is wrong. Post-validation check catches edge cases.
162 Returns:
163 Validated social network instance.
164 """
165 url_validator.validate_strings(self.url)
166 return self
168 @functools.cached_property
169 def url(self) -> str:
170 """Generate profile URL from network and username.
172 Why:
173 Users provide network+username for brevity. Property generates full
174 URLs with platform-specific logic (e.g., Mastodon domain extraction).
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
185 return url