Skip to content

Commit 15fb9bc

Browse files
sneakers-the-ratRobPasMue
authored andcommitted
compute translation stats during build, write stats to output directory not repo
1 parent 2ed8890 commit 15fb9bc

File tree

3 files changed

+242
-226
lines changed

3 files changed

+242
-226
lines changed

_ext/translation_graph.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
from pathlib import Path
2+
import json
3+
from typing import TYPE_CHECKING, TypeAlias, TypedDict, Annotated as A
4+
5+
from babel.messages import pofile
6+
from docutils import nodes
7+
from docutils.parsers.rst import Directive
8+
import plotly.graph_objects as go
9+
from plotly.offline import plot
10+
import numpy as np
11+
12+
if TYPE_CHECKING:
13+
from sphinx.application import Sphinx
14+
15+
16+
BASE_DIR = Path(__file__).resolve().parent.parent # Repository base directory
17+
LOCALES_DIR = BASE_DIR / "locales" # Locales directory
18+
STATIC_DIR = BASE_DIR / "_static" # Static directory
19+
20+
class ModuleStats(TypedDict):
21+
total: int
22+
translated: int
23+
fuzzy: int
24+
untranslated: int
25+
percentage: float
26+
27+
TranslationStats: TypeAlias = dict[A[str, "locale"], dict[A[str, "module"], ModuleStats]]
28+
29+
30+
class TranslationGraph(Directive):
31+
# Tells Sphinx that this directive can be used in the document body
32+
# and has no content
33+
has_content = False
34+
35+
# oddly, this is evaluated in the js not python,
36+
# so we treat customdata like a json object
37+
HOVER_TEMPLATE = """
38+
<b>%{customdata.module}</b><br>
39+
Translated: %{customdata.translated}<br>
40+
Fuzzy: %{customdata.fuzzy}<br>
41+
Untranslated: %{customdata.untranslated}<br>
42+
Total: %{customdata.total}<br>
43+
Completed: %{customdata.percentage}%
44+
"""
45+
def run(self):
46+
data = get_translation_stats()
47+
48+
# Sort data by locale and module
49+
data = {locale: dict(sorted(loc_stats.items())) for locale, loc_stats in sorted(data.items())}
50+
51+
# prepend english, everything set to 100%
52+
en = {module: ModuleStats(total=stats['total'], translated=stats['total'], fuzzy=stats['total'], untranslated=0, percentage=100) for module, stats in next(iter(data.values())).items()}
53+
data = {'en': en} | data
54+
55+
# Calculate average completion percentage for each locale and sort locales
56+
locale_completion = {locale: np.mean([stats['percentage'] for stats in loc_stats.values()]) for locale, loc_stats in data.items()}
57+
sorted_locales = sorted(locale_completion.keys(), key=lambda locale: locale_completion[locale], reverse=True)
58+
59+
# Reorder data based on sorted locales
60+
data = {locale: data[locale] for locale in sorted_locales}
61+
62+
# Update locales list after sorting
63+
locales = list(data.keys())
64+
modules = list(next(iter(data.values())).keys())
65+
66+
# Extract data to plot
67+
values = [[stats['percentage'] for stats in loc_stats.values()] for loc_stats in data.values()]
68+
hoverdata = [[{'module': module} | stats for module, stats in loc_stats.items()] for loc_stats in data.values()]
69+
70+
# Add text to display percentages directly in the heatmap boxes
71+
text = [[f"{int(stats['percentage'])}%" for stats in loc_stats.values()] for loc_stats in data.values()]
72+
73+
heatmap = go.Heatmap(
74+
x=modules,
75+
y=locales,
76+
z=values,
77+
text=text, # Add text to the heatmap
78+
texttemplate="%{text}", # Format the text to display directly
79+
textfont={"size": 15}, # Adjust font size for better readability
80+
xgap=5,
81+
ygap=5,
82+
customdata=np.array(hoverdata),
83+
hovertemplate=self.HOVER_TEMPLATE,
84+
name="", # Set the trace name to an empty string to remove "trace 0" from hoverbox
85+
colorbar={
86+
'orientation': 'h',
87+
'y': 0,
88+
"yanchor": "bottom",
89+
"yref": "container",
90+
"title": "Completion %",
91+
"thickness": 10,
92+
"tickvals": [12.5, 50, 87.5, 100], # Midpoints for each category
93+
"ticktext": ["0-25%", "25-75%", "75-<100%", "100%"], # Labels for categories
94+
},
95+
colorscale=[
96+
[0.0, "rgb(254, 255, 231)"], # 0-25%
97+
[0.25, "rgb(254, 255, 231)"],
98+
[0.25, "rgb(187, 130, 176)"], # 25-75%
99+
[0.75, "rgb(187, 130, 176)"],
100+
[0.75, "rgb(129, 192, 170)"], # 75-<100%
101+
[0.99, "rgb(129, 192, 170)"],
102+
[1.0, "rgb(78, 112, 100)"], # 100%
103+
],
104+
)
105+
# Create figure
106+
fig = go.Figure(data=heatmap)
107+
fig.update_layout(
108+
paper_bgcolor="rgba(0,0,0,0)",
109+
plot_bgcolor="rgba(0,0,0,0)",
110+
font_color="var(--bs-body-color)",
111+
margin=dict(l=40, r=40, t=40, b=40),
112+
xaxis_showgrid=False,
113+
xaxis_side="top",
114+
xaxis_tickangle=-45,
115+
xaxis_tickfont = {
116+
"family": "var(--bs-font-monospace)",
117+
},
118+
yaxis_showgrid=False,
119+
yaxis_title="Locale",
120+
yaxis_autorange="reversed",
121+
)
122+
div = plot(
123+
fig,
124+
output_type="div",
125+
include_plotlyjs=True,
126+
config={"displayModeBar": False},
127+
)
128+
return [nodes.raw("", div, format="html")]
129+
130+
131+
def calculate_translation_percentage(po_path : Path, locale : str) -> ModuleStats:
132+
"""
133+
Calculate the translation percentage for a given .po file.
134+
135+
Parameters
136+
----------
137+
po_path : Path
138+
Path to the .po file.
139+
locale : str
140+
Locale code (e.g., 'es', 'fr').
141+
142+
Returns
143+
-------
144+
dict
145+
A dictionary containing the total number of strings, translated strings,
146+
fuzzy strings, untranslated strings, and the translation percentage.
147+
"""
148+
with open(po_path, "r", encoding="utf-8") as f:
149+
catalog = pofile.read_po(f, locale=locale)
150+
151+
total = 0
152+
translated = 0
153+
fuzzy = 0
154+
155+
for message in catalog:
156+
if message.id:
157+
total += 1
158+
# Check if the message is fuzzy
159+
# Fuzzy messages are not considered translated
160+
if message.fuzzy:
161+
fuzzy += 1
162+
break
163+
# Check if the message is translated
164+
if message.string:
165+
translated += 1
166+
167+
percentage = (translated / total * 100) if total > 0 else 0
168+
169+
return {
170+
"total": total,
171+
"translated": translated,
172+
"fuzzy": fuzzy,
173+
"untranslated": total - translated - fuzzy,
174+
"percentage": round(percentage, 2)
175+
}
176+
177+
178+
def get_translation_stats() -> TranslationStats:
179+
# Get all .po files in the locales directory
180+
po_files = list(LOCALES_DIR.rglob("*.po"))
181+
182+
# Let's use a dictionary to store the results
183+
#
184+
# We will store the info as
185+
# {
186+
# "es": {
187+
# "file1": {
188+
# "total": 100,
189+
# "translated": 50,
190+
# "fuzzy": 0,
191+
# "untranslated": 50,
192+
# "percentage": 50.0
193+
# },
194+
# ...
195+
# },
196+
# "fr": {
197+
# "file1": {
198+
# "total": 100,
199+
# "translated": 50,
200+
# "fuzzy": 0,
201+
# "untranslated": 50,
202+
# "percentage": 50.0
203+
# },
204+
# ...
205+
# }
206+
results = {}
207+
208+
# Calculate translation percentages for each file
209+
for po_file in po_files:
210+
# Get the locale from the file path
211+
locale = po_file.parent.parent.name
212+
stats = calculate_translation_percentage(po_file, locale)
213+
214+
# Store the results in the dictionary
215+
if locale not in results:
216+
results[locale] = {}
217+
218+
results[locale][po_file.stem] = stats
219+
220+
return results
221+
222+
def write_translation_stats(app: "Sphinx", exception: Exception | None) -> None:
223+
from sphinx.util import logging
224+
logger = logging.getLogger("_ext.translation_graph")
225+
226+
stats = get_translation_stats()
227+
out_path = app.outdir / "_static" / "translation_stats.json"
228+
with open(out_path, "w") as f:
229+
json.dump(stats, f, indent=2)
230+
231+
logger.info("Wrote translation stats to %s", out_path)
232+
233+
234+
def setup(app):
235+
app.add_directive("translation-graph", TranslationGraph)
236+
app.connect("build-finished", write_translation_stats)
237+
238+
return {
239+
"version": "0.1",
240+
"parallel_read_safe": True,
241+
"parallel_write_safe": True,
242+
}

_static/translation_stats.json

Lines changed: 0 additions & 118 deletions
This file was deleted.

0 commit comments

Comments
 (0)