Skip to content

Commit 2ab38ea

Browse files
feat: Use Babel for numbers and dates to support many locales (#67)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fb1b40c commit 2ab38ea

14 files changed

+136
-237
lines changed

Diff for: api/index.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,7 @@ def render():
4343
thumbnail = data_uri_from_url(f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg")
4444
views = fetch_views(video_id)
4545
views = fetch_views(video_id, lang)
46-
diff = (
47-
format_relative_time(datetime.fromtimestamp(int(publish_timestamp)), lang)
48-
if publish_timestamp
49-
else ""
50-
)
46+
diff = format_relative_time(publish_timestamp, lang) if publish_timestamp else ""
5147
stats = f"{views} • {diff}" if views and diff else (views or diff)
5248
duration = seconds_to_duration(duration_seconds)
5349
duration_width = estimate_duration_width(duration)

Diff for: api/locale/bn.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
11
bn:
2+
view: "1 বার দেখা হয়েছে"
23
views: "%{number} বার দেখা হয়েছে"
3-
seconds-ago:
4-
one: "1 সেকেন্ড আগে"
5-
many: "%{count} সেকেন্ড আগে"
6-
minutes-ago:
7-
one: "1 মিনিট আগে"
8-
many: "%{count} মিনিট আগে"
9-
hours-ago:
10-
one: "1 ঘন্টা আগে"
11-
many: "%{count} ঘন্টা আগে"
12-
days-ago:
13-
one: "1 দিন আগে"
14-
many: "%{count} দিন আগে"
15-
months-ago:
16-
one: "1 মাস আগে"
17-
many: "%{count} মাস আগে"
18-
years-ago:
19-
one: "1 বছর পূর্বে"
20-
many: "%{count} বছর পূর্বে"

Diff for: api/locale/en.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
11
en:
2+
view: "1 view"
23
views: "%{number} views"
3-
seconds-ago:
4-
one: "1 second ago"
5-
many: "%{count} seconds ago"
6-
minutes-ago:
7-
one: "1 minute ago"
8-
many: "%{count} minutes ago"
9-
hours-ago:
10-
one: "1 hour ago"
11-
many: "%{count} hours ago"
12-
days-ago:
13-
one: "1 day ago"
14-
many: "%{count} days ago"
15-
months-ago:
16-
one: "1 month ago"
17-
many: "%{count} months ago"
18-
years-ago:
19-
one: "1 year ago"
20-
many: "%{count} years ago"

Diff for: api/locale/fa.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,4 @@
11
fa:
22
direction: rtl
3+
view: "1 بازدید"
34
views: "%{number} بازدید"
4-
seconds-ago:
5-
one: "یک ثانیه پیش"
6-
many: "%{count} ثانیه پیش"
7-
minutes-ago:
8-
one: "یک دقیقه پیش"
9-
many: "%{count} دقیقه پیش"
10-
hours-ago:
11-
one: "یک ساعت پیش"
12-
many: "%{count} ساعت پیش"
13-
days-ago:
14-
one: "یک روز پیش"
15-
many: "%{count} روز پیش"
16-
months-ago:
17-
one: "یک ماه پیش"
18-
many: "%{count} ماه پیش"
19-
years-ago:
20-
one: "یک سال پیش"
21-
many: "%{count} سال پیش"

Diff for: api/locale/fr.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
11
fr:
2+
view: "1 vue"
23
views: "%{number} vues"
3-
seconds-ago:
4-
one: "il y a 1 seconde"
5-
many: "il y a %{count} secondes"
6-
minutes-ago:
7-
one: "il y a 1 minute"
8-
many: "il y a %{count} minutes"
9-
hours-ago:
10-
one: "il y a 1 heure"
11-
many: "il y a %{count} heures"
12-
days-ago:
13-
one: "il y a 1 jour"
14-
many: "il y a %{count} jours"
15-
months-ago:
16-
one: "il y a 1 mois"
17-
many: "il y a %{count} mois"
18-
years-ago:
19-
one: "il y a 1 an"
20-
many: "il y a %{count} ans"

Diff for: api/locale/he.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,4 @@
11
he:
22
direction: rtl
3+
view: "1 צפייה"
34
views: "%{number} צפיות"
4-
seconds-ago:
5-
one: "לפני שניה"
6-
many: "לפני %{count} שניות"
7-
minutes-ago:
8-
one: "לפני דקה"
9-
many: "לפני %{count} דקות"
10-
hours-ago:
11-
one: "לפני שעה"
12-
many: "לפני %{count} שעות"
13-
days-ago:
14-
one: "אתמול"
15-
many: "לפני %{count} ימים"
16-
months-ago:
17-
one: "לפני חודש"
18-
many: "לפני %{count} חודשים"
19-
years-ago:
20-
one: "לפני שנה"
21-
many: "לפני %{count} שנים"

Diff for: api/locale/hi.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
11
hi:
2+
view: "1 बार देखा गया"
23
views: "%{number} बार देखा गया"
3-
seconds-ago:
4-
one: "1 सेकंड पहले"
5-
many: "%{count} सेकंड पहले"
6-
minutes-ago:
7-
one: "1 मिनट पहले"
8-
many: "%{count} मिनट पहले"
9-
hours-ago:
10-
one: "1 घंटे पहले"
11-
many: "%{count} घंटे पहले"
12-
days-ago:
13-
one: "1 दिन पहले"
14-
many: "%{count} दिन पहले"
15-
months-ago:
16-
one: "1 माह पहले"
17-
many: "%{count} माह पहले"
18-
years-ago:
19-
one: "1 वर्ष पहले"
20-
many: "%{count} वर्ष पहले"

Diff for: api/locale/id.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
11
id:
2+
view: "1 ditonton"
23
views: "%{number} ditonton"
3-
seconds-ago:
4-
one: "1 detik yang lalu"
5-
many: "%{count} detik yang lalu"
6-
minutes-ago:
7-
one: "1 menit yang lalu"
8-
many: "%{count} menit yang lalu"
9-
hours-ago:
10-
one: "1 jam yang lalu"
11-
many: "%{count} jam yang lalu"
12-
days-ago:
13-
one: "1 hari yang lalu"
14-
many: "%{count} hari yang lalu"
15-
months-ago:
16-
one: "1 bulan yang lalu"
17-
many: "%{count} bulan yang lalu"
18-
years-ago:
19-
one: "1 tahun yang lalu"
20-
many: "%{count} tahun yang lalu"

Diff for: api/locale/ur.yml

+1-18
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,4 @@
11
ur:
22
direction: rtl
3+
view: "1 ملاحظة"
34
views: "%{number} ملاحظات"
4-
seconds-ago:
5-
one: "1 سیکنڈ پہلے"
6-
many: "%{count} سیکنڈ پہلے"
7-
minutes-ago:
8-
one: "1 منٹ پہلے"
9-
many: "%{count} منٹ پہلے"
10-
hours-ago:
11-
one: "1 گھنٹہ پہلے"
12-
many: "%{count} گھنٹے پہلے"
13-
days-ago:
14-
one: "1 دن پہلے"
15-
many: "%{count} دن پہلے"
16-
months-ago:
17-
one: "1 مہینہ پہلے"
18-
many: "%{count} مہینے پہلے"
19-
years-ago:
20-
one: "1 سال پہلے"
21-
many: "%{count} سال پہلے"

Diff for: api/utils.py

+60-43
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,21 @@
11
import codecs
2-
from datetime import datetime
2+
from datetime import datetime, timedelta
33
from typing import Optional
44
from urllib.request import Request, urlopen
55

66
import i18n
77
import orjson
8+
from babel import Locale, dates, numbers
89

910
i18n.set("filename_format", "{locale}.{format}")
1011
i18n.set("enable_memoization", True)
1112
i18n.load_path.append("./api/locale")
1213

1314

14-
def format_relative_time(date: datetime, lang: str = "en") -> str:
15-
"""Get relative time from datetime (ex. "3 hours ago")"""
16-
# find time difference in seconds
17-
seconds_diff = int((datetime.now() - date).total_seconds())
18-
# number of days in difference
19-
days_diff = seconds_diff // 86400
20-
# less than 50 seconds ago
21-
if seconds_diff < 50:
22-
return i18n.t("seconds-ago", count=seconds_diff, locale=lang)
23-
# less than 2 minutes ago
24-
if seconds_diff < 120:
25-
return i18n.t("minutes-ago", count=1, locale=lang)
26-
# less than an hour ago
27-
if seconds_diff < 3600:
28-
return i18n.t("minutes-ago", count=seconds_diff // 60, locale=lang)
29-
# less than 2 hours ago
30-
if seconds_diff < 7200:
31-
return i18n.t("hours-ago", count=1, locale=lang)
32-
# less than 24 hours ago
33-
if seconds_diff < 86400:
34-
return i18n.t("hours-ago", count=seconds_diff // 3600, locale=lang)
35-
# 1 day ago
36-
if days_diff == 1:
37-
return i18n.t("days-ago", count=1, locale=lang)
38-
# less than a month ago
39-
if days_diff < 30:
40-
return i18n.t("days-ago", count=days_diff, locale=lang)
41-
# less than 12 months ago
42-
if days_diff < 336:
43-
if round(days_diff / 30.5) == 1:
44-
return i18n.t("months-ago", count=1, locale=lang)
45-
return i18n.t("months-ago", count=round(days_diff / 30.5), locale=lang)
46-
# more than a year ago
47-
if round(days_diff / 365) == 1:
48-
return i18n.t("years-ago", count=1, locale=lang)
49-
return i18n.t("years-ago", count=round(days_diff / 365), locale=lang)
15+
def format_relative_time(timestamp: float, lang: str = "en") -> str:
16+
"""Get relative time from unix timestamp (ex. "3 hours ago")"""
17+
delta = timedelta(seconds=timestamp - datetime.now().timestamp())
18+
return dates.format_timedelta(delta=delta, add_direction=True, locale=lang)
5019

5120

5221
def data_uri_from_bytes(*, data: bytes, mime_type: str) -> str:
@@ -93,19 +62,67 @@ def trim_text(text: str, max_length: int) -> str:
9362
return text[: max_length - 1].strip() + "…"
9463

9564

65+
def format_decimal_compact(number: float, lang: str = "en") -> str:
66+
"""Format number with compact notation (ex. "1.2K")
67+
68+
TODO: This can be refactored once it is supported by Babel
69+
Sees https://github.com/python-babel/babel/pull/909
70+
"""
71+
locale = Locale.parse(lang)
72+
compact_format = locale._data["compact_decimal_formats"]["short"]
73+
number_format = None
74+
for magnitude in sorted([int(m) for m in compact_format["other"]], reverse=True):
75+
if abs(number) >= magnitude:
76+
# check the pattern using "other" as the amount
77+
number_format = compact_format["other"][str(magnitude)]
78+
pattern = numbers.parse_pattern(number_format).pattern
79+
# if the pattern is "0", we do not divide the number
80+
if pattern == "0":
81+
break
82+
# otherwise, we need to divide the number by the magnitude but remove zeros
83+
# equal to the number of 0's in the pattern minus 1
84+
number = number / (magnitude / (10 ** (pattern.count("0") - 1)))
85+
# round to the number of fraction digits requested
86+
number = round(number, 1)
87+
# if the remaining number is like "one", use the singular format
88+
plural_form = locale.plural_form(abs(number))
89+
plural_form = plural_form if plural_form in compact_format else "other"
90+
number_format = compact_format[plural_form][str(magnitude)]
91+
break
92+
return numbers.format_decimal(
93+
number=number, format=number_format, locale=lang, decimal_quantization=False
94+
)
95+
96+
97+
def parse_metric_value(value: str) -> int:
98+
"""Parse a metric value (ex. "1.2K" => 1200)
99+
100+
See https://github.com/badges/shields/blob/master/services/text-formatters.js#L56
101+
for the reverse of this function.
102+
"""
103+
suffixes = ["k", "M", "G", "T", "P", "E", "Z", "Y"]
104+
if value[-1] in suffixes:
105+
return int(float(value[:-1]) * 1000 ** (suffixes.index(value[-1]) + 1))
106+
return int(value)
107+
108+
109+
def format_views_value(value: str, lang: str = "en") -> str:
110+
"""Format view count, for example "1.2M" => "1.2M views", translations included"""
111+
int_value = parse_metric_value(value)
112+
if int_value == 1:
113+
return i18n.t("view", locale=lang)
114+
formatted_value = format_decimal_compact(int_value, lang=lang)
115+
return i18n.t("views", number=formatted_value, locale=lang)
116+
117+
96118
def fetch_views(video_id: str, lang: str = "en") -> str:
97119
"""Get number of views for a YouTube video as a formatted metric"""
98120
try:
99121
req = Request(f"https://img.shields.io/youtube/views/{video_id}.json")
100122
req.add_header("User-Agent", "GitHub Readme YouTube Cards")
101123
with urlopen(req) as response:
102124
value = orjson.loads(response.read()).get("value", "")
103-
# replace G with B for billion and convert to uppercase
104-
return (
105-
i18n.t("views", number=value.replace("G", "B").upper(), locale=lang)
106-
if value
107-
else ""
108-
)
125+
return format_views_value(value, lang)
109126
except Exception:
110127
return ""
111128

Diff for: api/validate.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import os
21
import re
32

3+
from babel import Locale, UnknownLocaleError
44
from flask.wrappers import Request
55

66

@@ -40,12 +40,12 @@ def validate_string(req: Request, field: str, default: str = "") -> str:
4040

4141

4242
def validate_lang(req: Request, field: str, *, default: str = "en") -> str:
43-
"""Validate a string with a locale lang, returns the string if translations
44-
are present in the locale directory, otherwise the default.
43+
"""Validate a string with a locale lang, returns the string if the locale
44+
is known by Babel, otherwise the default.
4545
"""
46-
value = req.args.get(field, "")
47-
# if there is no yaml file for the language, use the default
48-
if not os.path.isfile(f"./api/locale/{value}.yml"):
49-
return default
50-
46+
value = req.args.get(field, default)
47+
try:
48+
Locale.parse(value)
49+
except UnknownLocaleError:
50+
value = default
5151
return value

Diff for: requirements.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
Flask==2.2.2
22
gunicorn>=20.1.0
33
orjson==3.8.0
4-
python-i18n[yaml]==0.3.9
4+
python-i18n[yaml]==0.3.9
5+
Babel==2.10.3

Diff for: tests/test_app.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,5 @@ def test_request_right_to_left(client):
9393
assert 'style="direction: rtl"' in data
9494

9595
# test views
96-
views_regex = re.compile(r"\d+(?:\.\d)?[KMBT]? צפיות")
96+
views_regex = re.compile(r"\d+(?:\.\d)?[KMBT]?\u200f צפיות")
9797
assert views_regex.search(data) is not None

0 commit comments

Comments
 (0)