mirror of https://github.com/eyhc1/rendercv.git
Merge pull request #10 from jpgoldberg/jpg/mastodon
Adds Mastodon social network
This commit is contained in:
commit
c4156bc7c9
|
@ -187,3 +187,8 @@ output/
|
||||||
# Personal CVs
|
# Personal CVs
|
||||||
Sina_Atalay_CV.yaml
|
Sina_Atalay_CV.yaml
|
||||||
run_sina_atalay_cv.py
|
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
|
||||||
|
|
|
@ -47,6 +47,9 @@ linting = ["black", "ruff"]
|
||||||
requires = ['setuptools>=68.2.2', "setuptools-scm>=8.0.4"]
|
requires = ['setuptools>=68.2.2', "setuptools-scm>=8.0.4"]
|
||||||
build-backend = 'setuptools.build_meta'
|
build-backend = 'setuptools.build_meta'
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["rendercv"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
|
|
|
@ -1022,10 +1022,10 @@ class PublicationEntry(Event):
|
||||||
class SocialNetwork(BaseModel):
|
class SocialNetwork(BaseModel):
|
||||||
"""This class stores a social network information.
|
"""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",
|
title="Social Network",
|
||||||
description="The social network name.",
|
description="The social network name.",
|
||||||
)
|
)
|
||||||
|
@ -1048,6 +1048,7 @@ class Connection(BaseModel):
|
||||||
"GitHub",
|
"GitHub",
|
||||||
"Instagram",
|
"Instagram",
|
||||||
"Orcid",
|
"Orcid",
|
||||||
|
"Mastodon",
|
||||||
"phone",
|
"phone",
|
||||||
"email",
|
"email",
|
||||||
"website",
|
"website",
|
||||||
|
@ -1055,6 +1056,91 @@ class Connection(BaseModel):
|
||||||
]
|
]
|
||||||
value: str
|
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
|
@computed_field
|
||||||
@cached_property
|
@cached_property
|
||||||
def url(self) -> Optional[HttpUrl | str]:
|
def url(self) -> Optional[HttpUrl | str]:
|
||||||
|
@ -1066,6 +1152,8 @@ class Connection(BaseModel):
|
||||||
url = f"https://www.instagram.com/{self.value}"
|
url = f"https://www.instagram.com/{self.value}"
|
||||||
elif self.name == "Orcid":
|
elif self.name == "Orcid":
|
||||||
url = f"https://orcid.org/{self.value}"
|
url = f"https://orcid.org/{self.value}"
|
||||||
|
elif self.name == "Mastodon":
|
||||||
|
url = self.MastodonUname2Url(self.value)
|
||||||
elif self.name == "email":
|
elif self.name == "email":
|
||||||
url = f"mailto:{self.value}"
|
url = f"mailto:{self.value}"
|
||||||
elif self.name == "website":
|
elif self.name == "website":
|
||||||
|
|
|
@ -15,6 +15,10 @@
|
||||||
\mbox{\hrefWithoutArrow{<<url>>}{{\small\faOrcid}\hspace{0.13cm}<<username>>}}
|
\mbox{\hrefWithoutArrow{<<url>>}{{\small\faOrcid}\hspace{0.13cm}<<username>>}}
|
||||||
((*- endmacro *))
|
((*- endmacro *))
|
||||||
|
|
||||||
|
((* macro Mastodon(username, url) -*))
|
||||||
|
\mbox{\hrefWithoutArrow{<<url>>}{{\small\faMastodon}\hspace{0.13cm}<<username>>}}
|
||||||
|
((*- endmacro *))
|
||||||
|
|
||||||
((* macro phone(number, url) -*))
|
((* macro phone(number, url) -*))
|
||||||
\mbox{\hrefWithoutArrow{<<url|replace("-","")>>}{{\footnotesize\faPhone*}\hspace{0.13cm}<<number|replace("tel:", "")|replace("-"," ")>>}}
|
\mbox{\hrefWithoutArrow{<<url|replace("-","")>>}{{\footnotesize\faPhone*}\hspace{0.13cm}<<number|replace("tel:", "")|replace("-"," ")>>}}
|
||||||
((*- endmacro *))
|
((*- endmacro *))
|
||||||
|
|
|
@ -1588,7 +1588,8 @@
|
||||||
"LinkedIn",
|
"LinkedIn",
|
||||||
"GitHub",
|
"GitHub",
|
||||||
"Instagram",
|
"Instagram",
|
||||||
"Orcid"
|
"Orcid",
|
||||||
|
"Mastodon"
|
||||||
],
|
],
|
||||||
"title": "Social Network",
|
"title": "Social Network",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
@ -5,7 +5,7 @@ import json
|
||||||
from rendercv import data_model
|
from rendercv import data_model
|
||||||
|
|
||||||
from datetime import date as Date
|
from datetime import date as Date
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
class TestDataModel(unittest.TestCase):
|
class TestDataModel(unittest.TestCase):
|
||||||
|
@ -945,3 +945,46 @@ class TestDataModel(unittest.TestCase):
|
||||||
with self.subTest(msg="nonexistent file"):
|
with self.subTest(msg="nonexistent file"):
|
||||||
with self.assertRaises(FileNotFoundError):
|
with self.assertRaises(FileNotFoundError):
|
||||||
data_model.read_input_file("nonexistent.json")
|
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()
|
||||||
|
|
Loading…
Reference in New Issue