diff --git a/.gitignore b/.gitignore index 36a9807..1c720ed 100644 --- a/.gitignore +++ b/.gitignore @@ -186,4 +186,9 @@ output/ # Personal CVs Sina_Atalay_CV.yaml -run_sina_atalay_cv.py \ No newline at end of file +run_sina_atalay_cv.py + +# Jeffrey Goldbergs local stuff +# We can remove these once work by him is finished +Jeffrey_Paul_Goldberg_CV.yaml +pyvenv.cfg diff --git a/pyproject.toml b/pyproject.toml index 1d92725..d52cb68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ linting = ["black", "ruff"] requires = ['setuptools>=68.2.2', "setuptools-scm>=8.0.4"] build-backend = 'setuptools.build_meta' +[tool.setuptools] +packages = ["rendercv"] + [tool.ruff] line-length = 88 diff --git a/rendercv/data_model.py b/rendercv/data_model.py index 32b60d9..aa8042a 100644 --- a/rendercv/data_model.py +++ b/rendercv/data_model.py @@ -1022,10 +1022,10 @@ class PublicationEntry(Event): class SocialNetwork(BaseModel): """This class stores a social network information. - Currently, only LinkedIn, Github, and Instagram are supported. + Currently, only LinkedIn, Github, Mastodon, and Instagram are supported. """ - network: Literal["LinkedIn", "GitHub", "Instagram", "Orcid"] = Field( + network: Literal["LinkedIn", "GitHub", "Instagram", "Orcid", "Mastodon"] = Field( title="Social Network", description="The social network name.", ) @@ -1048,6 +1048,7 @@ class Connection(BaseModel): "GitHub", "Instagram", "Orcid", + "Mastodon", "phone", "email", "website", @@ -1055,6 +1056,91 @@ class Connection(BaseModel): ] value: str + @staticmethod + def is_valid_hostname(hostname: str) -> bool: + """Is hostname a valid hostname by RFCs 952 and 1123""" + + # slightly modified from + # https://stackoverflow.com/a/33214423/1304076 + if hostname[-1] == ".": + # strip exactly one dot from the right, if present + hostname = hostname[:-1] + if len(hostname) > 253: + return False + + labels = hostname.split(".") + + # the last label must be not all-numeric + if re.match(r"[0-9]+$", labels[-1]): + return False + + # labels cannot begin with a hyphen + # labels must have at least character + # labels may not have more than 63 characters + allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(? Optional[HttpUrl]: + """returns profile url from a mastodon user address. + + Args: + address (str): A Mastodon user address. E.g., "user@social.example" + + Returns: + A pydantic HttpUrl object with the https URL for the user profile + + Example: + ``` + url = MastodonUname2Url("user@social.example") + assert(url == HttpUrl(http://social.example/@user)) + ``` + + Exceptions: + ValueError if the address is malformed. + Note that well-formed addresses should never yield + syntactically invalid URLs. + """ + + # The closest thing to a formal spec of Mastodon usernames + # where these regular expressions from a (reference?) + # implementation + # + # https://github.com/mastodon/mastodon/blob/f1657e6d6275384c199956e8872115fdcec600b0/app/models/account.rb#L68 + # + # USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i + # MENTION_RE = %r{(?[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?) # username part + @ # separator + (?P[a-z0-9]+([a-z0-9.-]+)?) # domain part + \s*$ # ignore trailing whitespace + """, + re.VERBOSE | re.IGNORECASE, + ) + + m = pattern.match(address) + if m is None: + raise ValueError("Invalid mastodon address") + uname = m.group("uname") + domain = m.group("domain") + + # the domain part of pattern allows some things that are not + # valid names. So we run a stricter check + if not Connection.is_valid_hostname(domain): + raise ValueError("Invalid hostname in mastodon address") + + url = HttpUrl(f"https://{domain}/@{uname}") + return url + @computed_field @cached_property def url(self) -> Optional[HttpUrl | str]: @@ -1066,6 +1152,8 @@ class Connection(BaseModel): url = f"https://www.instagram.com/{self.value}" elif self.name == "Orcid": url = f"https://orcid.org/{self.value}" + elif self.name == "Mastodon": + url = self.MastodonUname2Url(self.value) elif self.name == "email": url = f"mailto:{self.value}" elif self.name == "website": diff --git a/rendercv/templates/classic/components/header_connections.tex.j2 b/rendercv/templates/classic/components/header_connections.tex.j2 index a8e3f74..d1c95e9 100644 --- a/rendercv/templates/classic/components/header_connections.tex.j2 +++ b/rendercv/templates/classic/components/header_connections.tex.j2 @@ -15,6 +15,10 @@ \mbox{\hrefWithoutArrow{<>}{{\small\faOrcid}\hspace{0.13cm}<>}} ((*- endmacro *)) +((* macro Mastodon(username, url) -*)) +\mbox{\hrefWithoutArrow{<>}{{\small\faMastodon}\hspace{0.13cm}<>}} +((*- endmacro *)) + ((* macro phone(number, url) -*)) \mbox{\hrefWithoutArrow{<>}{{\footnotesize\faPhone*}\hspace{0.13cm}<>}} ((*- endmacro *)) diff --git a/schema.json b/schema.json index 02ebe7e..02a9305 100644 --- a/schema.json +++ b/schema.json @@ -1588,7 +1588,8 @@ "LinkedIn", "GitHub", "Instagram", - "Orcid" + "Orcid", + "Mastodon" ], "title": "Social Network", "type": "string" diff --git a/tests/test_data_model.py b/tests/test_data_model.py index 3c5ef79..bd3e492 100644 --- a/tests/test_data_model.py +++ b/tests/test_data_model.py @@ -5,7 +5,7 @@ import json from rendercv import data_model from datetime import date as Date -from pydantic import ValidationError +from pydantic import ValidationError, HttpUrl class TestDataModel(unittest.TestCase): @@ -945,3 +945,46 @@ class TestDataModel(unittest.TestCase): with self.subTest(msg="nonexistent file"): with self.assertRaises(FileNotFoundError): data_model.read_input_file("nonexistent.json") + + def test_mastodon_parsing(self): + mastodon_name = "a_tooter@example.exchange" + expected = HttpUrl("https://example.exchange/@a_tooter") + result = data_model.Connection.MastodonUname2Url(mastodon_name) + with self.subTest("Without '@' prefix"): + self.assertEqual(result, expected) + + mastodon_name = "@a_tooter@example.exchange" + expected = HttpUrl("https://example.exchange/@a_tooter") + result = data_model.Connection.MastodonUname2Url(mastodon_name) + with self.subTest("With '@' prefix"): + self.assertEqual(result, expected) + + mastodon_name = "@too@many@symbols" + with self.subTest("Too many '@' symbols"): + with self.assertRaises(ValueError): + data_model.Connection.MastodonUname2Url(mastodon_name) + + mastodon_name = "@not_enough_at_symbols" + with self.subTest("Missing '@' separator"): + with self.assertRaises(ValueError): + data_model.Connection.MastodonUname2Url(mastodon_name) + + mastodon_name = "user@bad_domain.example" + with self.subTest("Underscore in domain portion"): + with self.assertRaises(ValueError): + data_model.Connection.MastodonUname2Url(mastodon_name) + + mastodon_name = "user@bad.numeric.tld.123" + with self.subTest("All digit TLD"): + with self.assertRaises(ValueError): + data_model.Connection.MastodonUname2Url(mastodon_name) + + mastodon_name = "a_tooter@example.exchange." + expected = HttpUrl("https://example.exchange./@a_tooter") + result = data_model.Connection.MastodonUname2Url(mastodon_name) + with self.subTest("With FQDN root '.'"): + self.assertEqual(result, expected) + + +if __name__ == "__main__": + unittest.main()