Merge pull request #10 from jpgoldberg/jpg/mastodon

Adds Mastodon social network
This commit is contained in:
Sina Atalay 2024-01-26 18:24:02 +01:00 committed by GitHub
commit c4156bc7c9
6 changed files with 149 additions and 5 deletions

5
.gitignore vendored
View File

@ -187,3 +187,8 @@ output/
# Personal CVs
Sina_Atalay_CV.yaml
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

View File

@ -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

View File

@ -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}(?<!-)$", re.IGNORECASE)
return all(allowed.match(label) for label in labels)
@staticmethod
def MastodonUname2Url(address: str) -> 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{(?<![=/[:word:]])@((#{USERNAME_RE})(?:@[[:word:].-]+[[:word:]]+)?)}i
#
# `[[:word:]]` in Ruby includes lots of things that could never be in a # domain name. As my intent here is to construct an HTTPS URL,
# What we need are valid hostnames,
# and so need to satisfy the constraints of RFC 952 and and 1123.
pattern = re.compile(
r"""
^\s* # ignore leading spaces
@? # Optional @ prefix
(?P<uname>[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?) # username part
@ # separator
(?P<domain>[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":

View File

@ -15,6 +15,10 @@
\mbox{\hrefWithoutArrow{<<url>>}{{\small\faOrcid}\hspace{0.13cm}<<username>>}}
((*- endmacro *))
((* macro Mastodon(username, url) -*))
\mbox{\hrefWithoutArrow{<<url>>}{{\small\faMastodon}\hspace{0.13cm}<<username>>}}
((*- endmacro *))
((* macro phone(number, url) -*))
\mbox{\hrefWithoutArrow{<<url|replace("-","")>>}{{\footnotesize\faPhone*}\hspace{0.13cm}<<number|replace("tel:", "")|replace("-"," ")>>}}
((*- endmacro *))

View File

@ -1588,7 +1588,8 @@
"LinkedIn",
"GitHub",
"Instagram",
"Orcid"
"Orcid",
"Mastodon"
],
"title": "Social Network",
"type": "string"

View File

@ -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()