Skip to content

Commit 4b355a6

Browse files
authored
Merge branch 'master' into dependabot/uv/_scripts/uv-aa7cb66ac2
2 parents 17b3234 + c6cd7d8 commit 4b355a6

259 files changed

Lines changed: 68291 additions & 39577 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/translate.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ jobs:
6868
runs-on: ubuntu-latest
6969
strategy:
7070
fail-fast: false # Continue other languages if one fails
71+
max-parallel: 5 # Limit concurrent languages to avoid API rate limits
7172
matrix:
7273
language: ${{ fromJson(needs.detect.outputs.languages) }}
7374
steps:

_scripts/translate/chunking.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""Heading-based chunking for incremental translation.
2+
3+
Splits markdown files at ``## ``-level headings and compares old vs new
4+
English chunks to determine which sections actually changed. Unchanged
5+
sections reuse the existing translation, avoiding expensive LLM calls.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
import re
12+
from dataclasses import dataclass, field
13+
from difflib import SequenceMatcher
14+
15+
from .postprocess import CODE_FENCE_RE
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# Data structures
20+
# ---------------------------------------------------------------------------
21+
22+
23+
@dataclass
24+
class Chunk:
25+
"""A section of a markdown file delimited by ``## `` headings."""
26+
27+
heading: str
28+
"""The heading line itself (e.g. ``## 2. Publish outputs``), or empty
29+
string for the preamble (chunk 0)."""
30+
31+
content: str
32+
"""Full text of the chunk **including** the heading line."""
33+
34+
index: int
35+
"""Position of this chunk in the file (0-based)."""
36+
37+
content_hash: str = field(default="", repr=False)
38+
"""MD5 hex digest of *content* (set automatically by ``split_into_chunks``)."""
39+
40+
41+
@dataclass
42+
class ChunkDiff:
43+
"""Result of comparing old and new chunk lists."""
44+
45+
unchanged: list[tuple[int, int]]
46+
"""(old_idx, new_idx) pairs for chunks with identical content."""
47+
48+
modified: list[tuple[int, int]]
49+
"""(old_idx, new_idx) pairs for chunks whose content changed."""
50+
51+
added: list[int]
52+
"""new_idx values with no match in the old list."""
53+
54+
removed: list[int]
55+
"""old_idx values with no match in the new list."""
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Splitting
60+
# ---------------------------------------------------------------------------
61+
62+
63+
def _content_hash(text: str) -> str:
64+
"""Hash content with trailing whitespace stripped for stable comparison.
65+
66+
Trailing blank lines shift between chunks when sections are added or
67+
removed, so we strip them before hashing to avoid false "modified"
68+
classifications.
69+
"""
70+
return hashlib.md5(text.rstrip().encode()).hexdigest()[:12]
71+
72+
73+
def split_into_chunks(text: str) -> list[Chunk]:
74+
"""Split markdown *text* into chunks at ``## `` headings.
75+
76+
* Chunk 0 is the "preamble" — everything before the first ``## ``.
77+
* Each subsequent chunk starts at a ``## `` line (outside code blocks)
78+
and includes all content up to (but not including) the next ``## ``.
79+
* ``## `` lines inside fenced code blocks are ignored.
80+
"""
81+
lines = text.split("\n")
82+
chunks: list[Chunk] = []
83+
current_lines: list[str] = []
84+
current_heading = ""
85+
in_code = False
86+
fence = ""
87+
88+
def _flush():
89+
content = "\n".join(current_lines)
90+
chunks.append(
91+
Chunk(
92+
heading=current_heading,
93+
content=content,
94+
index=len(chunks),
95+
content_hash=_content_hash(content),
96+
)
97+
)
98+
99+
for line in lines:
100+
stripped = line.lstrip()
101+
102+
# Track code fences
103+
if not in_code:
104+
if m := CODE_FENCE_RE.match(stripped):
105+
in_code, fence = True, m.group(1)
106+
elif stripped.startswith(fence) and stripped.strip() == fence:
107+
in_code, fence = False, ""
108+
109+
# Start a new chunk at each ## heading (outside code blocks)
110+
if not in_code and line.startswith("## "):
111+
_flush()
112+
current_lines = [line]
113+
current_heading = line
114+
else:
115+
current_lines.append(line)
116+
117+
_flush()
118+
return chunks
119+
120+
121+
def reassemble_chunks(chunks: list[Chunk]) -> str:
122+
"""Reassemble chunks into a single document string."""
123+
return "\n".join(c.content for c in chunks)
124+
125+
126+
# ---------------------------------------------------------------------------
127+
# Diffing
128+
# ---------------------------------------------------------------------------
129+
130+
131+
def _normalize_heading(heading: str) -> str:
132+
"""Normalize heading text for comparison (strip whitespace, anchors)."""
133+
# Remove trailing anchor IDs like { #some-id }
134+
h = re.sub(r"\s*\{[^}]*\}\s*$", "", heading)
135+
return h.strip()
136+
137+
138+
def diff_chunks(old_chunks: list[Chunk], new_chunks: list[Chunk]) -> ChunkDiff:
139+
"""Compare old and new chunk lists to identify changes.
140+
141+
Matching strategy (applied in order):
142+
1. **Heading match**: exact heading text (after normalization).
143+
2. **Content hash match**: same content, different heading (detects
144+
renames/reorders without content changes).
145+
3. **Fuzzy heading match**: heading similarity >0.8 via SequenceMatcher
146+
(detects heading renames with content changes).
147+
"""
148+
unchanged: list[tuple[int, int]] = []
149+
modified: list[tuple[int, int]] = []
150+
151+
# Track which chunks have been matched
152+
matched_old: set[int] = set()
153+
matched_new: set[int] = set()
154+
155+
# Build lookup maps
156+
old_by_heading: dict[str, list[int]] = {}
157+
for c in old_chunks:
158+
key = _normalize_heading(c.heading)
159+
old_by_heading.setdefault(key, []).append(c.index)
160+
161+
old_by_hash: dict[str, list[int]] = {}
162+
for c in old_chunks:
163+
old_by_hash.setdefault(c.content_hash, []).append(c.index)
164+
165+
# Pass 1: exact heading match
166+
for nc in new_chunks:
167+
key = _normalize_heading(nc.heading)
168+
candidates = old_by_heading.get(key, [])
169+
for oi in candidates:
170+
if oi not in matched_old:
171+
matched_old.add(oi)
172+
matched_new.add(nc.index)
173+
if old_chunks[oi].content_hash == nc.content_hash:
174+
unchanged.append((oi, nc.index))
175+
else:
176+
modified.append((oi, nc.index))
177+
break
178+
179+
# Pass 2: content hash match (for reordered/renamed chunks)
180+
for nc in new_chunks:
181+
if nc.index in matched_new:
182+
continue
183+
candidates = old_by_hash.get(nc.content_hash, [])
184+
for oi in candidates:
185+
if oi not in matched_old:
186+
matched_old.add(oi)
187+
matched_new.add(nc.index)
188+
unchanged.append((oi, nc.index))
189+
break
190+
191+
# Pass 3: fuzzy heading match
192+
unmatched_old = [c for c in old_chunks if c.index not in matched_old]
193+
unmatched_new = [c for c in new_chunks if c.index not in matched_new]
194+
195+
for nc in list(unmatched_new):
196+
best_score = 0.0
197+
best_oc = None
198+
nc_heading = _normalize_heading(nc.heading)
199+
for oc in unmatched_old:
200+
oc_heading = _normalize_heading(oc.heading)
201+
score = SequenceMatcher(None, oc_heading, nc_heading).ratio()
202+
if score > best_score:
203+
best_score = score
204+
best_oc = oc
205+
if best_oc is not None and best_score > 0.8:
206+
matched_old.add(best_oc.index)
207+
matched_new.add(nc.index)
208+
unmatched_old.remove(best_oc)
209+
unmatched_new.remove(nc)
210+
if best_oc.content_hash == nc.content_hash:
211+
unchanged.append((best_oc.index, nc.index))
212+
else:
213+
modified.append((best_oc.index, nc.index))
214+
215+
# Remaining unmatched
216+
added = [nc.index for nc in unmatched_new]
217+
removed = [oc.index for oc in unmatched_old]
218+
219+
return ChunkDiff(
220+
unchanged=unchanged,
221+
modified=modified,
222+
added=added,
223+
removed=removed,
224+
)

_scripts/translate/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def _sync_normal(
124124
check_api_key()
125125
baseline = resolve_baseline(since, console)
126126

127-
orphaned, missing, outdated = gather_work(lang, baseline)
127+
orphaned, missing, outdated = gather_work(lang, baseline, console)
128128

129129
prompts_changed = baseline and prompt_changed_since(lang, baseline)
130130
if prompts_changed:

_scripts/translate/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
# Translation settings
2626
MAX_CONTINUATIONS = 5 # Max continuation requests for very large files
2727
MAX_VERIFY_RETRIES = 2 # Re-translation attempts after verification failure
28-
DEFAULT_PARALLEL = 50
28+
DEFAULT_PARALLEL = 10
2929
PRIORITY_DIRS = ["hello_nextflow", "hello_nf-core", "nf4_science", "envsetup"]
3030

3131
# Comment styles by language (used for code block post-processing)

0 commit comments

Comments
 (0)