-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathtest_translations.py
More file actions
1431 lines (1113 loc) · 59.9 KB
/
Copy pathtest_translations.py
File metadata and controls
1431 lines (1113 loc) · 59.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The documentation translation tool (`scripts/docs/translations.py`).
Everything here is tool-defined behaviour: what the model must never change is
re-imposed from the English page (or, for links, checked against it), unchanged
sections survive re-translation byte for byte, a page's state lives in its own
front matter, and a language site is the English tree with translations laid
over it and a notice on every page. The model is a scripted fake and the
repository a `tmp_path` tree, so every test is offline and deterministic.
"""
import json
import threading
from collections.abc import Sequence
from pathlib import Path
import pytest
import translations as t
from inline_snapshot import snapshot
MKDOCS = """\
site_name: Test docs
nav:
- Home: index.md
- Tools: tools.md
- Translations: translations.md
- Migration: migration.md
- API Reference: api/
markdown_extensions:
- admonition
- attr_list
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.snippets:
check_paths: true
"""
LANGUAGES = """\
model: test-model
exclude: [migration.md]
languages:
- code: ja
name: 日本語
theme: ja
hreflang: ja
"""
NOTICES = """\
# Notices
## Machine translation {#translated}
Machine translated; the [English page](ENGLISH_PAGE) is authoritative. See [Translations](TRANSLATIONS_PAGE).
## Translation behind the English page {#outdated}
Parts may be out of date; compare the [English page](ENGLISH_PAGE).
## Shown in English {#english}
Not translated yet; [Translations](TRANSLATIONS_PAGE) explains why.
"""
NOTICES_JA = """\
# お知らせ
## 機械翻訳 {#translated}
機械翻訳です。正式版は[英語版](ENGLISH_PAGE)です。[翻訳について](TRANSLATIONS_PAGE)を参照。
## 英語版より古い翻訳 {#outdated}
一部が古い可能性があります。[英語版](ENGLISH_PAGE)と比べてください。
## 英語で表示 {#english}
未翻訳です。理由は[翻訳について](TRANSLATIONS_PAGE)を参照。
"""
GLOSSARY = {
"keep": ["MCP", "Python"],
"terms": [
{"source": "tool", "target": "ツール", "avoid": ["道具"]},
{"source": "server", "target": "サーバー", "note": "Katakana, long vowel kept."},
],
}
INDEX = """\
# Home
Welcome to MCP. Read about [tools](tools.md#errors) or the [API](api/mcp/index.md).
## Install
Run `pip install mcp`, then read the [Python](https://www.python.org/) docs.
"""
TOOLS = """\
# Tools
A **tool** is a function the model can call; start at [home](index.md#install).
## Your first tool
```python title="server.py"
--8<-- "docs_src/server.py"
```
!!! note "Heads up"
Every tool is `async` friendly.
## Errors
Raise to signal a failure.
"""
# English front matter (even with a `#` comment line in it) is dropped wherever the page is read.
TRANSLATIONS = (
"---\ndescription: About the translated sites.\n# not a heading\n---\n# Translations\n\nHow this works.\n"
)
# Faithful model replies: prose translated, code, link targets and markers untouched, no `{#id}` pins.
INDEX_JA = """\
# ホーム
MCP へようこそ。[ツール](tools.md#errors)または [API](api/mcp/index.md) を参照してください。
## インストール
`pip install mcp` を実行し、[Python](https://www.python.org/) のドキュメントを読みます。
"""
TRANSLATIONS_JA = "# 翻訳について\n\n仕組みの説明です。\n"
TOOLS_JA = """\
# ツール
**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。
## 最初のツール
```python title="server.py"
--8<-- "docs_src/server.py"
```
!!! note "注意"
どのツールも `async` に対応しています。
## エラー
失敗を伝えるには例外を送出します。
"""
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
def make_repo(tmp_path: Path) -> Path:
"""A repository with three English prose pages, an excluded page and the ja inputs, nothing translated."""
root = tmp_path / "repo"
write(root / "mkdocs.yml", MKDOCS)
write(root / "docs" / "index.md", INDEX)
write(root / "docs" / "tools.md", TOOLS)
write(root / "docs" / "translations.md", TRANSLATIONS)
write(root / "docs" / "migration.md", "# Migration\n\nSee [errors](tools.md#errors).\n")
write(root / "docs" / "img" / "logo.svg", "<svg/>\n")
write(root / "docs_src" / "server.py", "print('hi')\n")
write(root / "i18n" / "languages.yml", LANGUAGES)
write(root / "i18n" / "general-prompt.md", "General rules.\n")
write(root / "i18n" / "notices.md", NOTICES)
write(root / "i18n" / "ja" / "instructions.md", "Japanese rules.\n")
write(root / "i18n" / "ja" / "glossary.json", json.dumps(GLOSSARY, ensure_ascii=False))
return root
class FakeTranslator:
"""Answers each call with the next scripted reply (raising it if it is an exception) and records the calls."""
def __init__(self, replies: Sequence[str | t.Completion | Exception]) -> None:
self.replies = list(replies)
self.models: list[str] = []
self.systems: list[str] = []
self.conversations: list[list[t.Message]] = []
def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion:
self.models.append(model)
self.systems.append(system)
self.conversations.append(list(messages))
reply = self.replies.pop(0)
if isinstance(reply, Exception):
raise reply
return reply if isinstance(reply, t.Completion) else t.Completion(reply, t.Usage(1000, 400, 900, 100))
def run(
capsys: pytest.CaptureFixture[str], root: Path, *argv: str, translator: t.Translator | None = None
) -> tuple[int, str, str]:
"""`(exit code, stdout, stderr)` of one command line."""
code = t.main(list(argv), root=root, translator=translator)
captured = capsys.readouterr()
return code, captured.out, captured.err
def translate(
capsys: pytest.CaptureFixture[str], root: Path, *argv: str, jobs: int = 1, translator: t.Translator | None = None
) -> tuple[int, str, str]:
"""`translate ARGV --jobs JOBS`: one page at a time by default, so scripted replies pair with pages in
nav order and the log has one possible order."""
return run(capsys, root, "translate", *argv, "--jobs", str(jobs), translator=translator)
def translate_all(capsys: pytest.CaptureFixture[str], root: Path) -> None:
"""Publish faithful translations of the three pages and the notices through the real command."""
fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA])
assert translate(capsys, root, "--lang", "ja", translator=fake)[0] == 0
def test_sections_tile_the_page_and_blank_lines_belong_to_the_heading_after_them() -> None:
"""Tool-defined: a page splits into intro + one string per `##` (a `##` inside a fence is code), the parts
join back to the page, and appending a section leaves every earlier section's hash unchanged."""
page = "# Title\n\nIntro.\n\n\n## One\n\n```md\n## not a heading\n```\n\n## Two\n\nEnd.\n"
assert t.sections(page) == snapshot(
[
"""\
# Title
Intro.
""",
"""\
## One
```md
## not a heading
```
""",
"""\
## Two
End.
""",
]
)
assert "".join(t.sections(page)) == page
assert t.section_hashes(page + "\n## Three\n\nMore.\n")[:3] == t.section_hashes(page)
def test_provenance_front_matter_round_trips_and_keeps_all_digit_hashes_as_strings() -> None:
"""Tool-defined: the generated file is front matter + body; a hash of digits only must not come back as
a number, and a block from another tool version or without the record reads as no provenance at all."""
hashes = ("1234567890123456", "00ff00ff00ff00ff")
text = t.with_provenance("# 本文\n", hashes)
assert text == snapshot("""\
---
translation:
sections: ['1234567890123456', 00ff00ff00ff00ff]
tool: 1
---
# 本文
""")
front_matter, body = t.split_front_matter(text)
assert (t.read_provenance(front_matter), body) == (hashes, "# 本文\n")
assert t.read_provenance("translation: {sections: [], tool: 99}\n") is None
assert t.read_provenance("title: Just a page\n") is None
def test_heading_ids_are_the_ones_the_site_renderer_produces(tmp_path: Path) -> None:
"""Tool-defined: ids come from the real markdown stack (dedupe suffixes, punctuation, `__init__`, explicit
ids, inline code, no space after `##`), and pinning them in escaped source form renders the same ids."""
repo = t.load_repo(make_repo(tmp_path))
page = (
"# What's new?\n\n## Step\n\n## Step\n\n## The `__init__` hook\n\n## Custom {#my-id}\n\n"
"##Glued\n\n## Über `Config.load()` & friends!\n\n## 日本語\n"
)
ids = repo.heading_ids(page)
assert ids == snapshot(
["whats-new", "step", "step_1", "the-__init__-hook", "my-id", "glued", "uber-configload-friends", "_1"]
)
pinned = t.reimpose(page, ids, page)
assert isinstance(pinned, str)
assert pinned.split("\n")[6] == snapshot("## The `__init__` hook {#the-\\_\\_init\\_\\_-hook}")
assert repo.heading_ids(pinned) == ids
def test_heading_the_source_scan_cannot_pin_makes_the_page_an_error(tmp_path: Path) -> None:
"""Tool-defined: a setext heading renders but is not an ATX heading at column 0, so ids cannot be paired
positionally; the page is refused rather than mis-pinned."""
repo = t.load_repo(make_repo(tmp_path))
with pytest.raises(t.PageError) as excinfo:
repo.heading_ids("# Title\n\nSetext\n------\n")
assert str(excinfo.value) == snapshot("the page renders 2 headings but 1 are ATX headings at column 0")
def test_many_attribute_blocks_pin_the_last_id_only_when_they_end_the_heading() -> None:
"""Tool-defined: a run of `{...}` blocks ending the line pins the last block's id, in either attr_list
spelling; with text after it the run stays heading text and pins nothing, and the scan still returns
promptly however many blocks (colon-led ones too) it has."""
blocks, colons = "{ a } " * 20, "{: #a}" * 20
page = f"## Pinned {blocks}{{#last}}\n## Prose {blocks}end\n## Colons {colons} tail\n## A {{:#a}}\n## B {{: #a }}\n"
assert t.parse_headings(page) == [
t.Heading(0, 2, "Pinned", "last"),
t.Heading(1, 2, f"Prose {blocks}end", None),
t.Heading(2, 2, f"Colons {colons} tail", None),
t.Heading(3, 2, "A", "a"),
t.Heading(4, 2, "B", "a"),
]
ENGLISH = """\
# Guide
See [tools](tools.md#errors), the [spec](https://spec.example/) and .
## The `__init__` hook
```python title="app.py" hl_lines="1"
--8<-- "docs_src/server.py"
def main(): ... # (1)!
```
## Step
## Step
"""
def test_reimpose_restores_fences_pins_ids_and_leaves_reordered_links_where_the_translation_put_them() -> None:
"""Tool-defined: the wrapper fence is dropped, each fence comes back opener-through-closer, ids are pinned
positionally in escaped form (a `{#...}` glued to CJK text, or doubled, is replaced), and links the
translation reordered within a section keep their own targets: nothing is moved back by position."""
reply = (
"```markdown\n# ガイド\n\n、[仕様](https://spec.example/)、[ツール](tools.md#errors)。\n\n"
"## `__init__` フック{#init}\n\n```py\ndef メイン(): ...\n```\n\n## 手順\n\n## 手順 {#wrong} {: #twice }\n```"
)
result = t.reimpose(ENGLISH, ["guide", "the-__init__-hook", "step", "step_1"], t.unwrap(ENGLISH, reply))
assert result == snapshot("""\
# ガイド {#guide}
、[仕様](https://spec.example/)、[ツール](tools.md#errors)。
## `__init__` フック {#the-\\_\\_init\\_\\_-hook}
```python title="app.py" hl_lines="1"
--8<-- "docs_src/server.py"
def main(): ... # (1)!
```
## 手順 {#step}
## 手順 {#step_1}
""")
def test_unwrap_ends_the_reply_with_exactly_the_trailing_newlines_of_the_english() -> None:
"""Tool-defined: a reply with newlines to spare, or none, is stored ending the way its English page ends,
so the last section's bytes do not depend on how the model happened to close its reply."""
assert t.unwrap("# Title\n", "```markdown\n# タイトル\n```\n\n\n") == "# タイトル\n"
assert t.unwrap("# Title\n", "# タイトル\n\n") == "# タイトル\n"
assert t.unwrap("# Title\n", "# タイトル") == "# タイトル\n"
@pytest.mark.parametrize(
("reply", "findings"),
[
pytest.param(
ENGLISH.replace("## Step\n\n## Step\n", "## Step\n"),
snapshot(["3 headings vs 4 in the English: keep every heading, and no others"]),
id="heading-dropped",
),
pytest.param(
ENGLISH.replace("## Step\n\n", "### Step\n\n"),
snapshot(["`Step` is a level-3 heading but `Step` is level 2"]),
id="heading-level",
),
pytest.param(
ENGLISH.replace('hl_lines="1"\n', 'hl_lines="1"\n```\n\n```text\n'),
snapshot(["## The `__init__` hook: 2 code fences vs 1 in the English: keep each where it is, add none"]),
id="fence-added",
),
pytest.param(
ENGLISH.replace("## Step\n\n## Step\n", "## Step\n\n```python\npass\n```\n\n## Step\n").replace(
'```python title="app.py" hl_lines="1"\n--8<-- "docs_src/server.py"\ndef main(): ... # (1)!\n```\n', ""
),
snapshot(
[
"## The `__init__` hook: 0 code fences vs 1 in the English: keep each where it is, add none",
"## Step: 1 code fences vs 0 in the English: keep each where it is, add none",
]
),
id="fence-moved-to-another-section",
),
pytest.param(
ENGLISH.replace("\n```\n\n## Step", "\n\n## Step"),
snapshot(
[
"the code fence opened on line 7 is never closed",
"2 headings vs 4 in the English: keep every heading, and no others",
]
),
id="fence-unclosed",
),
pytest.param(
ENGLISH.replace("tools.md#errors", "tools.md#エラー"),
snapshot(
[
"missing links to ['tools.md#errors']: keep every link of the English where it is",
"unexpected links to ['tools.md#エラー']: add no links of your own",
]
),
id="target-mangled",
),
pytest.param(
ENGLISH.replace("See [tools](tools.md#errors), the", "See the").replace(
"## Step\n\n## Step\n", "## Step\n\nSee [tools](tools.md#errors).\n\n## Step\n"
),
snapshot(
[
"missing links to ['tools.md#errors']: keep every link of the English where it is",
"unexpected links to ['tools.md#errors']: add no links of your own",
]
),
id="link-moved-to-another-section",
),
],
)
def test_reimpose_names_each_structural_mismatch(reply: str, findings: list[str]) -> None:
"""Tool-defined: a heading count or level the reply gets wrong, or a section's fence count (so a code
block moved under another `##` counts twice), cannot be repaired positionally, and a link target its
English section lacks (mangled, or moved across sections) is never rewritten, so each becomes a finding
for the repair turn."""
result = t.reimpose(ENGLISH, ["guide", "the-__init__-hook", "step", "step_1"], reply)
assert result == t.Mismatch(findings)
def test_validate_flags_code_spans_markers_banned_terms_and_abridgement() -> None:
"""Tool-defined: what re-imposition cannot fix is reported for the repair turn; a banned rendering
inside code is not prose, and a link label or anything the English itself says is not an abridgement."""
glossary = t.Glossary(("MCP",), (t.Term("tool", "ツール", "", ("道具",)),))
english = '# T\n\nUse `ctx` and `run()`. See [Translations](t.md), not [...].\n\n!!! tip "Hint"\n A table.\n'
faithful = (
"# T\n\n`ctx` と `run()` を使います。`道具` はコード。[Translations](t.md) 参照、[...] 以外。\n\n"
'!!! tip "ヒント"\n 表。\n'
)
broken = (
"# T\n\n`ctx` と `run` を使う道具です。[translation continues below]\n\n"
'!!! note "ヒント"\n <!-- rest of the table omitted -->\n'
)
assert t.validate(english, english, glossary) == []
assert t.validate(english, faithful.replace("`道具` はコード。", ""), glossary) == []
assert t.validate(english, faithful, glossary) == snapshot(
["unexpected inline code ['道具']: use only the English `code spans`"]
)
assert t.validate(english, broken, glossary) == snapshot(
[
"missing inline code ['run()']: copy every `code span` of the English",
"unexpected inline code ['run']: use only the English `code spans`",
"block markers ['!!! note'] vs ['!!! tip'] in the English: keep each `!!!`/`???`/`===` line and its type",
"banned rendering '道具' of 'tool' appears: use 'ツール'",
"placeholder '<!-- rest of the table omitted -->': translate the whole page, never abridge it",
"placeholder '[translation continues below]': translate the whole page, never abridge it",
]
)
LISTED = """\
# Translations
How this works:
- one
- two
1. nested
* three
| Note | Meaning |
|------|---------|
| a | b |
| c | d |
```text
- not an item
| not | a row |
```
"""
LISTED_JA = """\
# 翻訳について
仕組みの説明です。
- いち
- に
1. 入れ子
* さん
| お知らせ | 意味 |
|------|---------|
| a | b |
| c | d |
```text
- not an item
| not | a row |
```
"""
def test_validate_counts_list_items_and_table_rows_whatever_the_language_of_a_placeholder() -> None:
"""Tool-defined: a reply that drops list items (at any depth, any bullet) or table rows is a finding
naming the part of the page, even when the note it leaves in their place is not English; lines inside
fenced code do not count."""
glossary = t.Glossary((), ())
shortened = LISTED_JA.replace(" 1. 入れ子\n* さん\n", "(以下同様)\n").replace("| c | d |\n", "")
assert t.block_counts(LISTED) == {"list items": 4, "table rows": 4}
assert t.validate(LISTED, LISTED_JA, glossary) == []
assert t.validate(LISTED, shortened, glossary, "## Some section") == snapshot(
[
"## Some section: 2 list items vs 4 in the English: translate them one for one, dropping none",
"## Some section: 3 table rows vs 4 in the English: translate them one for one, dropping none",
]
)
@pytest.mark.parametrize(
("glossary", "message"),
[
pytest.param({"keep": [], "terms": [{"source": "a", "target": "b", "enforce": True}]}, "TypeError(", id="key"),
pytest.param({"keep": [], "terms": ["tool"]}, "ValueError(", id="entry-string"),
pytest.param({"keep": "MCP", "terms": []}, "`keep` must be a list of strings\n", id="keep-string"),
pytest.param(
{"keep": [], "terms": [{"source": "host", "target": "Host", "avoid": "Gastgeber"}]},
"`terms` entry {'source': 'host', 'target': 'Host', 'avoid': 'Gastgeber'} needs string"
" `source`/`target`/`note` and a list `avoid`\n",
id="avoid-string",
),
],
)
def test_glossary_of_the_wrong_shape_stops_translate_with_exit_2_but_never_breaks_stage(
tmp_path: Path, capsys: pytest.CaptureFixture[str], glossary: dict[str, object], message: str
) -> None:
"""Tool-defined: a glossary entry with a key `Term` lacks, an entry that is no object, or a bare string
where a list belongs, stops the command that prompts with it (exit 2, naming the file and the field) before
any page work; the site build's `stage` never reads prompt inputs, so a broken one cannot fail a build."""
root = make_repo(tmp_path)
path = root / "i18n" / "ja" / "glossary.json"
write(path, json.dumps(glossary))
code, out, err = translate(capsys, root, "--lang", "ja", translator=FakeTranslator([]))
assert (code, out) == (2, "")
assert err.startswith(f"translations: {path}: {message}") # an exception's own text is the interpreter's
assert run(capsys, root, "stage", "--lang", "ja") == (0, "staged ja at .build/i18n/ja/docs\n", "")
def test_translate_writes_pages_with_provenance_and_a_second_run_makes_no_calls(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: missing pages are translated whole and written with pinned ids and provenance front
matter; once every page is current a run selects nothing and never builds a client."""
root = make_repo(tmp_path)
fake = FakeTranslator([INDEX_JA, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, err) == (0, "")
assert out == snapshot("""\
translating 4 pages (ja), 1 at a time, with test-model
ja: translated index.md (2 of 2 sections)
ja: translated tools.md (3 of 3 sections)
ja: translated translations.md (1 of 1 sections)
ja: translated i18n/notices.md (4 of 4 sections)
usage: 4000 input / 1600 output / 3600 cache-write / 400 cache-read tokens
""")
assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == snapshot("""\
---
translation:
sections: [66b1e7a79f363f39, 0c72bd9638620faf, 21db181e57737c09]
tool: 1
---
# ツール {#tools}
**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。
## 最初のツール {#your-first-tool}
```python title="server.py"
--8<-- "docs_src/server.py"
```
!!! note "注意"
どのツールも `async` に対応しています。
## エラー {#errors}
失敗を伝えるには例外を送出します。
""")
assert fake.conversations[1] == [t.Message("user", t.translate_request(TOOLS))]
assert fake.systems[0] == snapshot("""\
General rules.
# Target language: 日本語 (`ja`)
Japanese rules.
## Glossary
These terms always stay in English, spelled exactly like this:
- MCP
- Python
Use these renderings; the notes are binding:
- tool → ツール (never: 道具)
- server → サーバー. Katakana, long vowel kept.\
""")
code, out, err = translate(capsys, root, "--lang", "ja")
assert (code, out, err) == (0, "ja: nothing to translate\n", "")
assert run(capsys, root, "status") == snapshot(
(0, "ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n", "")
)
def test_docs_translate_model_overrides_the_registry_model_for_the_run(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Tool-defined: calls use the registry's model unless `DOCS_TRANSLATE_MODEL` names another one to
trial, and neither is ever written into the generated page."""
root = make_repo(tmp_path)
monkeypatch.delenv("DOCS_TRANSLATE_MODEL", raising=False)
fake = FakeTranslator([INDEX_JA, INDEX_JA])
assert translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0
monkeypatch.setenv("DOCS_TRANSLATE_MODEL", "trial-model")
code, _, _ = translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake)
assert (code, fake.models) == (0, ["test-model", "trial-model"])
assert "model" not in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8")
def test_translate_pages_retranslates_the_named_pages_from_scratch_even_when_a_translation_exists(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: `--pages` sends exactly the request a missing page gets (the English alone, no previous
translation to anchor on) although the page is current, publishes the result with provenance, and a page
outside the nav is exit 2."""
root = make_repo(tmp_path)
translate_all(capsys, root)
fake = FakeTranslator([INDEX_JA.replace("へようこそ", "へようこそ!")])
code, out, _ = translate(capsys, root, "--lang", "ja", "--pages", "index.md", translator=fake)
assert (code, out.split("\n")[1]) == (0, "ja: translated index.md (2 of 2 sections)")
assert fake.conversations[0] == [t.Message("user", t.translate_request(INDEX))]
assert "MCP へようこそ!" in (root / "i18n" / "ja" / "pages" / "index.md").read_text(encoding="utf-8")
assert run(capsys, root, "status")[1] == snapshot("ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n")
code, _, err = translate(capsys, root, "--lang", "ja", "--pages", "nope.md", translator=fake)
assert (code, err) == snapshot(
(2, "translations: not translatable pages (nav paths such as servers/tools.md): ['nope.md']\n")
)
def test_outdated_page_retranslates_the_changed_section_and_carries_the_rest_forward(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: editing one English section marks the page outdated; the prompt carries the previous
translation and names that section for retranslation, and every other section keeps its previous bytes
even though the model re-rendered them."""
root = make_repo(tmp_path)
translate_all(capsys, root)
write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail."))
reply = TOOLS_JA.replace("**ツール**は", "気まぐれな言い換え:**ツール**は").replace(
"失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。"
)
fake = FakeTranslator([reply])
code, out, _ = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, out.split("\n")[1]) == (0, "ja: translated tools.md (1 of 3 sections)")
assert fake.conversations[0][0].content == snapshot("""\
This page was translated before. Retranslate it: translate the sections listed below
afresh from the current English, applying the current language instructions and glossary
(their previous wording may be outdated); everywhere else, reproduce the previous
translation line by line, changing nothing. Keep the retranslated sections consistent in
terminology and tone with their surroundings. A section is the introduction before the
first `##` heading, or one `##` heading with everything under it.
Sections to retranslate:
- ## Errors
Current English page:
<english-page>
# Tools
A **tool** is a function the model can call; start at [home](index.md#install).
## Your first tool
```python title="server.py"
--8<-- "docs_src/server.py"
```
!!! note "Heads up"
Every tool is `async` friendly.
## Errors
Raise `ToolError` to fail.
</english-page>
Previous translation of the page:
<previous-translation>
# ツール {#tools}
**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。
## 最初のツール {#your-first-tool}
```python title="server.py"
--8<-- "docs_src/server.py"
```
!!! note "注意"
どのツールも `async` に対応しています。
## エラー {#errors}
失敗を伝えるには例外を送出します。
</previous-translation>
Return only the full translated page.\
""")
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
assert "気まぐれ" not in body
assert body.endswith("## エラー {#errors}\n\n失敗するには `ToolError` を送出します。\n")
def test_banned_rendering_is_a_finding_in_a_retranslated_section_but_not_in_a_carried_one(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: validation reads the page as it will be written, and only the sections this run rewrites.
A carried section using a word the glossary has since banned is published text (`--pages` redoes it), so
it is neither a finding nor touched; the same word in the retranslated section is repaired as ever."""
root = make_repo(tmp_path)
translate_all(capsys, root)
banned = {"source": "function", "target": "ファンクション", "avoid": ["関数"]} # the published intro says 関数
write(root / "i18n" / "ja" / "glossary.json", json.dumps({**GLOSSARY, "terms": [*GLOSSARY["terms"], banned]}))
write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise from the function."))
slipped = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "関数から送出します。")
repaired = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "ファンクションから送出します。")
fake = FakeTranslator([slipped, repaired])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, err, fake.replies, out.split("\n")[1]) == (0, "", [], "ja: translated tools.md (1 of 3 sections)")
assert fake.conversations[1][2].content == snapshot("""\
Your translation broke the following structural rules. Fix each problem and return the
full corrected page, changing nothing else:
- banned rendering '関数' of 'function' appears: use 'ファンクション'\
""")
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
assert t.sections(body)[0] == t.sections(TOOLS_JA)[0].replace("# ツール\n", "# ツール {#tools}\n")
assert body.endswith("## エラー {#errors}\n\nファンクションから送出します。\n")
def test_link_dropped_in_a_carried_section_costs_no_repair_turn_and_the_stored_section_is_kept(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: a reply is assembled with the carried sections before its structure is checked, so a link
the model lost in a section this run discards anyway is no finding: one call, and the published intro is
the stored one, link and all."""
root = make_repo(tmp_path)
translate_all(capsys, root)
write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail."))
reply = TOOLS_JA.replace("[ホーム](index.md#install)から", "ホームから").replace(
"失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。"
)
fake = FakeTranslator([reply])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, err, out.split("\n")[1]) == (0, "", "ja: translated tools.md (1 of 3 sections)")
assert [len(conversation) for conversation in fake.conversations] == [1] # one call, no repair turn
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
assert t.sections(body)[0] == t.sections(TOOLS_JA)[0].replace("# ツール\n", "# ツール {#tools}\n")
assert body.endswith("## エラー {#errors}\n\n失敗するには `ToolError` を送出します。\n")
def test_removed_english_section_is_reassembled_with_no_client_but_an_edited_one_needs_credentials_first(
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Tool-defined, in three runs: (1) when English sections are only removed or reordered, every remaining
section still has its recorded translation, so the page is rebuilt from them with no model call and no
client, hence no credentials; (2) once some page has an edited section the run calls the model, and
missing credentials stop it before any page, rebuildable ones included, is written; (3) with credentials
that run rebuilds the one page and retranslates the edited section of the other."""
root = make_repo(tmp_path)
translate_all(capsys, root)
def no_credentials() -> t.Translator:
raise t.ConfigError("no API credentials: set ANTHROPIC_API_KEY")
monkeypatch.setattr(t, "anthropic_translator", no_credentials)
write(root / "docs" / "tools.md", TOOLS.split("## Errors")[0].rstrip("\n") + "\n")
code, out, _ = translate(capsys, root, "--lang", "ja")
assert (code, out.split("\n")[1]) == (0, "ja: translated tools.md (0 of 2 sections)")
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
previous = TOOLS_JA.split("## エラー")[0].rstrip("\n") + "\n"
assert body == previous.replace("# ツール\n", "# ツール {#tools}\n").replace(
"## 最初のツール\n", "## 最初のツール {#your-first-tool}\n"
)
assert run(capsys, root, "status")[1] == snapshot("ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n")
write(root / "docs" / "tools.md", TOOLS.split("## Your first tool")[0].rstrip("\n") + "\n")
write(root / "docs" / "index.md", INDEX.replace("Welcome to MCP.", "Welcome!"))
code, out, err = translate(capsys, root, "--lang", "ja")
assert (code, out, err) == snapshot((2, "", "translations: no API credentials: set ANTHROPIC_API_KEY\n"))
assert run(capsys, root, "status", "--lang", "ja")[1] == snapshot("""\
ja (日本語): 0 missing, 2 outdated, 2 current, 0 removable
outdated index.md (English changed in: the introduction (everything before the first `##` heading))
outdated tools.md (English sections removed or reordered)
""")
fake = FakeTranslator([INDEX_JA.replace("MCP へようこそ。", "ようこそ!")])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, err, fake.replies, out.split("\n")[1:3]) == snapshot(
(0, "", [], ["ja: translated index.md (1 of 2 sections)", "ja: translated tools.md (0 of 1 sections)"])
)
def test_a_failing_page_does_not_stop_the_run_and_the_exit_code_is_1(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: an API error, a refusal or a truncated reply fails that page only; later pages are still
written, the failures are reported on stderr, usage is totalled, and the run exits 1."""
root = make_repo(tmp_path)
refusal = t.Completion("", t.Usage(10, 0, 0, 0), "refusal")
truncated = t.Completion(TOOLS_JA[:40], t.Usage(10, 64_000, 0, 0), "max_tokens")
fake = FakeTranslator([t.PageError("API request failed: overloaded"), truncated, refusal, NOTICES_JA])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, out, err) == snapshot(
(
1,
"""\
translating 4 pages (ja), 1 at a time, with test-model
ja: translated i18n/notices.md (4 of 4 sections)
usage: 1020 input / 64400 output / 900 cache-write / 100 cache-read tokens
""",
"""\
ja: error: index.md: API request failed: overloaded
ja: error: tools.md: the reply was cut off at 64000 output tokens
ja: error: translations.md: the model declined to translate this page
""",
)
)
assert not (root / "i18n" / "ja" / "pages").exists()
assert (root / "i18n" / "ja" / "notices.md").is_file()
def test_rejected_credentials_stop_the_run_with_exit_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""Tool-defined: an authentication failure is configuration, not a page problem: nothing more is tried."""
root = make_repo(tmp_path)
fake = FakeTranslator([t.ConfigError("the API rejected the credentials: invalid x-api-key"), INDEX_JA])
code, out, err = translate(capsys, root, "--lang", "ja", translator=fake)
assert (code, out, err) == snapshot(
(
2,
"""\
translating 4 pages (ja), 1 at a time, with test-model
usage: 0 input / 0 output / 0 cache-write / 0 cache-read tokens
""",
"translations: the API rejected the credentials: invalid x-api-key\n",
)
)
assert fake.replies == [INDEX_JA]
def test_jobs_keeps_that_many_pages_in_flight_and_publishes_each_before_starting_another(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: with `--jobs 2` over four pages, two requests are always waiting on the model together
(neither of a pair is answered until both wait, from two threads), never three, and by the time the
third and fourth requests are made an earlier page is already on disk: a finished page is written
when it lands, and only then does the next one start."""
root = make_repo(tmp_path)
generated = root / "i18n" / "ja" / "pages"
replies = {"# Home": INDEX_JA, "# Tools": TOOLS_JA, "# Translations": TRANSLATIONS_JA, "# Notices": NOTICES_JA}
class Pairs:
"""Answers requests two at a time, noting how many were in flight and what was published by then."""
def __init__(self) -> None:
self.both, self.lock = threading.Barrier(2, timeout=5), threading.Lock()
self.in_flight = self.most = 0
self.published: list[list[str]] = []
def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion:
with self.lock:
self.in_flight += 1
self.most = max(self.most, self.in_flight)
self.published.append(sorted(path.name for path in generated.glob("*.md")))
try:
self.both.wait()
title = next(title for title in replies if title in messages[0].content)
return t.Completion(replies[title], t.Usage(10, 4, 0, 9))
finally:
with self.lock:
self.in_flight -= 1
fake = Pairs()
code, out, err = translate(capsys, root, "--lang", "ja", jobs=2, translator=fake)
assert (code, err, fake.most) == (0, "", 2)
assert fake.published[:2] == [[], []]
assert all(fake.published[2:]) and len(fake.published) == 4
assert sorted(out.splitlines()) == [
"ja: translated i18n/notices.md (4 of 4 sections)",
"ja: translated index.md (2 of 2 sections)",
"ja: translated tools.md (3 of 3 sections)",
"ja: translated translations.md (1 of 1 sections)",
"translating 4 pages (ja), 2 at a time, with test-model",
"usage: 40 input / 16 output / 0 cache-write / 36 cache-read tokens",
]
assert run(capsys, root, "status") == (0, "ja (日本語): 0 missing, 0 outdated, 4 current, 0 removable\n", "")
class RejectedTogether:
"""Lets two requests in together, then rejects the credentials of those whose page title is in `rejected`."""
def __init__(self, rejected: Sequence[str]) -> None:
self.rejected, self.calls = rejected, 0
self.both, self.lock = threading.Barrier(2, timeout=5), threading.Lock()
def complete(self, *, model: str, system: str, messages: Sequence[t.Message], max_tokens: int) -> t.Completion:
with self.lock:
self.calls += 1
self.both.wait()
if any(title in messages[0].content for title in self.rejected):
raise t.ConfigError("the API rejected the credentials: invalid bearer token")
return t.Completion(TOOLS_JA, t.Usage(10, 4, 0, 9))
def test_rejected_credentials_keep_a_page_that_lands_from_the_same_flight(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Tool-defined: with two pages in flight together, the credentials being rejected for one does not throw
away the other: whichever lands first, the good page is written and usage reported before exit 2."""
root = make_repo(tmp_path)
fake = RejectedTogether(["# Home"])
code, out, err = translate(capsys, root, "--lang", "ja", "--pages", "index.md", "tools.md", jobs=2, translator=fake)
assert (code, fake.calls, err) == (2, 2, "translations: the API rejected the credentials: invalid bearer token\n")
assert out == snapshot("""\
translating 2 pages (ja), 2 at a time, with test-model
ja: translated tools.md (3 of 3 sections)
usage: 10 input / 4 output / 0 cache-write / 9 cache-read tokens
""")
assert sorted(path.name for path in (root / "i18n" / "ja" / "pages").glob("*.md")) == ["tools.md"]
def test_rejected_credentials_with_pages_in_flight_start_no_further_page(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None: