1247 lines
52 KiB
Python
1247 lines
52 KiB
Python
"""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
|
|
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]:
|
|
code = t.main(list(argv), root=root, translator=translator)
|
|
captured = capsys.readouterr()
|
|
return code, captured.out, captured.err
|
|
|
|
|
|
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 run(capsys, root, "translate", "--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 = run(capsys, root, "translate", "--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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err) == (0, "")
|
|
assert out == snapshot("""\
|
|
translated: index.md (2 of 2 sections)
|
|
translated: tools.md (3 of 3 sections)
|
|
translated: translations.md (1 of 1 sections)
|
|
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 = run(capsys, root, "translate", "--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 run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0
|
|
monkeypatch.setenv("DOCS_TRANSLATE_MODEL", "trial-model")
|
|
|
|
code, _, _ = run(capsys, root, "translate", "--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, _ = run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)
|
|
|
|
assert (code, out.split("\n")[0]) == (0, "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 = run(capsys, root, "translate", "--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, _ = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, out.split("\n")[0]) == (0, "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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err, out.split("\n")[0]) == (0, "", "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, _ = run(capsys, root, "translate", "--lang", "ja")
|
|
|
|
assert (code, out.split("\n")[0]) == (0, "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 = run(capsys, root, "translate", "--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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err, fake.replies, out.split("\n")[:2]) == snapshot(
|
|
(0, "", [], ["translated: index.md (1 of 2 sections)", "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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, out, err) == snapshot(
|
|
(
|
|
1,
|
|
"""\
|
|
translated: i18n/notices.md (4 of 4 sections)
|
|
usage: 1020 input / 64400 output / 900 cache-write / 100 cache-read tokens
|
|
""",
|
|
"""\
|
|
error: index.md: API request failed: overloaded
|
|
error: tools.md: the reply was cut off at 64000 output tokens
|
|
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 = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, out, err) == snapshot((2, "", "translations: the API rejected the credentials: invalid x-api-key\n"))
|
|
assert fake.replies == [INDEX_JA]
|
|
|
|
|
|
def test_repair_turn_feeds_the_findings_back_and_accepts_the_fixed_reply(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: a reply that drops a code span and mistypes an admonition gets its findings appended to
|
|
the conversation; the corrected second reply is published."""
|
|
root = make_repo(tmp_path)
|
|
broken = TOOLS_JA.replace("`async` に", "非同期に").replace("!!! note", "!!! warning")
|
|
fake = FakeTranslator([INDEX_JA, broken, TOOLS_JA, TRANSLATIONS_JA, NOTICES_JA])
|
|
|
|
code, _, err = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err, fake.replies) == (0, "", [])
|
|
assert [message.role for message in fake.conversations[2]] == ["user", "assistant", "user"]
|
|
assert fake.conversations[2][1] == t.Message("assistant", broken)
|
|
assert fake.conversations[2][2].content == snapshot("""\
|
|
Your translation broke the following structural rules. Fix each problem and return the
|
|
full corrected page, changing nothing else:
|
|
|
|
- missing inline code ['async']: copy every `code span` of the English
|
|
- block markers ['!!! warning'] vs ['!!! note'] in the English: keep each `!!!`/`???`/`===` line and its type\
|
|
""")
|
|
|
|
|
|
FENCE_JA = '```python title="server.py"\n--8<-- "docs_src/server.py"\n```\n\n'
|
|
# The page's fence count kept, but the code block moved from its own `##` section into the next one.
|
|
FENCE_MOVED_JA = TOOLS_JA.replace(FENCE_JA, "").replace("## エラー\n\n", "## エラー\n\n" + FENCE_JA)
|
|
|
|
|
|
def test_code_block_moved_into_another_section_is_repaired_not_published(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: fences are counted section by section, so a reply that keeps every code block but puts
|
|
one under the wrong `##` heading gets a finding per section for the repair turn instead of the English
|
|
code spliced under the wrong prose; the corrected reply is published."""
|
|
root = make_repo(tmp_path)
|
|
fake = FakeTranslator([FENCE_MOVED_JA, TOOLS_JA])
|
|
|
|
code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake)
|
|
|
|
assert (code, err, fake.replies) == (0, "", [])
|
|
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:
|
|
|
|
- ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none
|
|
- ## Errors: 1 code fences vs 0 in the English: keep each where it is, add none\
|
|
""")
|
|
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
|
|
assert body.endswith("## エラー {#errors}\n\n失敗を伝えるには例外を送出します。\n")
|
|
|
|
|
|
def test_code_block_moved_out_of_a_retranslated_section_gets_repair_turns_like_any_other_finding(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: on an update the same slip (the retranslated section's reply swallows the code block of
|
|
the carried section before it) is fed back for repair rather than failing the page outright; the carried
|
|
section is the stored one by then, so only the retranslated section's extra fence is named, and the fixed
|
|
reply is assembled with the carried sections."""
|
|
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."))
|
|
fixed = TOOLS_JA.replace("失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。")
|
|
moved = FENCE_MOVED_JA.replace("失敗を伝えるには例外を送出します。", "失敗するには `ToolError` を送出します。")
|
|
fake = FakeTranslator([moved, fixed])
|
|
|
|
code, out, err = run(capsys, root, "translate", "--lang", "ja", translator=fake)
|
|
|
|
assert (code, err, fake.replies, out.split("\n")[0]) == (0, "", [], "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:
|
|
|
|
- ## Errors: 1 code fences vs 0 in the English: keep each where it is, add none\
|
|
""")
|
|
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
|
|
assert body == TOOLS_JA.replace("# ツール\n", "# ツール {#tools}\n").replace(
|
|
"## 最初のツール\n", "## 最初のツール {#your-first-tool}\n"
|
|
).replace(
|
|
"## エラー\n\n失敗を伝えるには例外を送出します。",
|
|
"## エラー {#errors}\n\n失敗するには `ToolError` を送出します。",
|
|
)
|
|
|
|
|
|
def test_shortened_list_and_table_are_fed_back_for_repair_before_the_page_is_published(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: a reply that cuts a list short behind a note in the target language and drops a table
|
|
row passes every other check, so the item and row counts are what send it back; the full reply is
|
|
published."""
|
|
root = make_repo(tmp_path)
|
|
write(root / "docs" / "translations.md", LISTED)
|
|
shortened = LISTED_JA.replace(" 1. 入れ子\n* さん\n", "(以下同様)\n").replace("| c | d |\n", "")
|
|
fake = FakeTranslator([shortened, LISTED_JA])
|
|
|
|
code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "translations.md", translator=fake)
|
|
|
|
assert (code, err, fake.replies) == (0, "", [])
|
|
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:
|
|
|
|
- the introduction (everything before the first `##` heading): 2 list items vs 4 in the English: translate them one for one, dropping none
|
|
- the introduction (everything before the first `##` heading): 3 table rows vs 4 in the English: translate them one for one, dropping none\
|
|
""")
|
|
body = t.split_front_matter((root / "i18n" / "ja" / "pages" / "translations.md").read_text(encoding="utf-8"))[1]
|
|
assert body == LISTED_JA.replace("# 翻訳について\n", "# 翻訳について {#translations}\n")
|
|
|
|
|
|
def test_page_still_broken_after_two_repair_turns_fails_and_keeps_the_previous_translation(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: three structurally wrong replies (first try + two repairs) fail the page; the file that
|
|
was published before stays byte for byte."""
|
|
root = make_repo(tmp_path)
|
|
translate_all(capsys, root)
|
|
before = (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8")
|
|
missing_fence = TOOLS_JA.replace('```python title="server.py"\n--8<-- "docs_src/server.py"\n```\n\n', "")
|
|
fake = FakeTranslator([missing_fence] * 3)
|
|
|
|
code, _, err = run(capsys, root, "translate", "--lang", "ja", "--pages", "tools.md", translator=fake)
|
|
|
|
assert (code, fake.replies) == (1, [])
|
|
assert err == snapshot(
|
|
"error: tools.md: unfixed after 2 repairs: ## Your first tool: 0 code fences vs 1 in the English: keep each where it is, add none\n"
|
|
)
|
|
assert (root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_status_classifies_each_page_against_the_current_english_and_lists_removable_files(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: a mangled provenance block reads as missing (with the reason, so the next run redoes the
|
|
page whole), an edited English section as outdated (naming it), and a generated file whose page left the
|
|
translatable set (excluded here; dropped from the nav works the same) as removable, with its `git rm`."""
|
|
root = make_repo(tmp_path)
|
|
translate_all(capsys, root)
|
|
index = root / "i18n" / "ja" / "pages" / "index.md"
|
|
write(index, index.read_text(encoding="utf-8").replace(" tool: 1\n", ""))
|
|
write(root / "docs" / "tools.md", TOOLS.replace("Raise to signal a failure.", "Raise `ToolError` to fail."))
|
|
write(root / "i18n" / "languages.yml", LANGUAGES.replace("[migration.md]", "[migration.md, translations.md]"))
|
|
|
|
assert run(capsys, root, "status", "--lang", "ja") == snapshot(
|
|
(
|
|
0,
|
|
"""\
|
|
ja (日本語): 1 missing, 1 outdated, 1 current, 1 removable
|
|
missing index.md (unreadable front matter, retranslated whole)
|
|
outdated tools.md (English changed in: ## Errors)
|
|
removable translations.md (git rm i18n/ja/pages/translations.md)
|
|
""",
|
|
"",
|
|
)
|
|
)
|
|
|
|
|
|
def staged_page(root: Path, page: str) -> str:
|
|
return (root / ".build" / "i18n" / "ja" / "docs" / page).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_stage_overlays_translations_injects_notices_and_rewrites_api_links(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: the ja tree is the English docs minus `api/`; a current page is served translated with
|
|
the collapsed machine-translation notice under its H1 (English wording while the notices page itself is
|
|
untranslated), an untranslated or excluded page is English (front matter dropped) with the
|
|
shown-in-English notice, links into `api/` go to the English site's reference, page-relative like the
|
|
notice links so they hold under any path prefix, and assets ride along."""
|
|
root = make_repo(tmp_path)
|
|
fake = FakeTranslator([INDEX_JA])
|
|
assert run(capsys, root, "translate", "--lang", "ja", "--pages", "index.md", translator=fake)[0] == 0
|
|
write(root / "docs" / "api" / "mcp" / "index.md", "# API stub\n")
|
|
|
|
code, out, err = run(capsys, root, "stage", "--lang", "ja")
|
|
|
|
assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\n", "")
|
|
assert staged_page(root, "index.md") == snapshot("""\
|
|
# ホーム {#home}
|
|
|
|
??? note "Machine translation"
|
|
|
|
Machine translated; the [English page](../) is authoritative. See [Translations](translations.md).
|
|
|
|
MCP へようこそ。[ツール](tools.md#errors)または [API](../api/mcp/) を参照してください。
|
|
|
|
## インストール {#install}
|
|
|
|
`pip install mcp` を実行し、[Python](https://www.python.org/) のドキュメントを読みます。
|
|
""")
|
|
assert staged_page(root, "tools.md") == snapshot("""\
|
|
# Tools
|
|
|
|
!!! note "Shown in English"
|
|
|
|
Not translated yet; [Translations](translations.md) explains why.
|
|
|
|
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.
|
|
""")
|
|
assert staged_page(root, "migration.md") == snapshot("""\
|
|
# Migration
|
|
|
|
!!! note "Shown in English"
|
|
|
|
Not translated yet; [Translations](translations.md) explains why.
|
|
|
|
See [errors](tools.md#errors).
|
|
""")
|
|
assert staged_page(root, "translations.md") == snapshot("""\
|
|
# Translations
|
|
|
|
!!! note "Shown in English"
|
|
|
|
Not translated yet; [Translations](translations.md) explains why.
|
|
|
|
How this works.
|
|
""")
|
|
assert staged_page(root, "img/logo.svg") == "<svg/>\n"
|
|
assert not (root / ".build" / "i18n" / "ja" / "docs" / "api").exists()
|
|
assert json.loads((root / ".build" / "i18n" / "ja" / "titles.json").read_text(encoding="utf-8")) == snapshot(
|
|
{"index.md": "ホーム", "migration.md": "Migration", "tools.md": "Tools", "translations.md": "Translations"}
|
|
)
|
|
|
|
|
|
def test_stage_serves_an_outdated_translation_exactly_as_generated_and_a_missing_one_in_english(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: after English-only edits under published translations, a page whose English sections were
|
|
reordered, edited and added to is staged byte for byte as it was generated (its ids and code were pinned
|
|
against its own English then), under the translated "outdated" notice linking today's English page; a page
|
|
whose generated file is unusable is staged in English under the "english" notice."""
|
|
root = make_repo(tmp_path)
|
|
translate_all(capsys, root)
|
|
generated = t.split_front_matter((root / "i18n" / "ja" / "pages" / "tools.md").read_text(encoding="utf-8"))[1]
|
|
intro, first_tool, errors = t.sections(TOOLS.replace("Raise to signal a failure.", "Raise `ToolError`."))
|
|
write(root / "docs" / "tools.md", intro + errors + first_tool + "\n## More\n\nText.\n")
|
|
index = root / "i18n" / "ja" / "pages" / "index.md"
|
|
write(index, index.read_text(encoding="utf-8").replace(" tool: 1\n", ""))
|
|
|
|
code, out, err = run(capsys, root, "stage", "--lang", "ja")
|
|
|
|
assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\n", "")
|
|
staged = staged_page(root, "tools.md")
|
|
assert staged == snapshot("""\
|
|
# ツール {#tools}
|
|
|
|
!!! note "英語版より古い翻訳"
|
|
|
|
一部が古い可能性があります。[英語版](../tools/)と比べてください。
|
|
|
|
**ツール**はモデルが呼び出せる関数です。[ホーム](index.md#install)から始めましょう。
|
|
|
|
## 最初のツール {#your-first-tool}
|
|
|
|
```python title="server.py"
|
|
--8<-- "docs_src/server.py"
|
|
```
|
|
|
|
!!! note "注意"
|
|
どのツールも `async` に対応しています。
|
|
|
|
## エラー {#errors}
|
|
|
|
失敗を伝えるには例外を送出します。
|
|
""")
|
|
assert staged.split("\n\n", 3)[3] == generated.split("\n\n", 1)[1] # after the notice: the generated body
|
|
assert staged_page(root, "index.md").split("\n")[:3] == ["# Home", "", '!!! note "英語で表示"']
|
|
|
|
|
|
def test_stage_keeps_showing_the_generated_code_block_after_its_english_changes_until_the_page_is_retranslated(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined, and the price of serving pages as generated: an edit inside an English code fence reaches a
|
|
language site only with the next translate run (which copies the fence from the English whatever the model
|
|
replies); until then the page shows the fence it was generated with, under the "outdated" notice."""
|
|
root = make_repo(tmp_path)
|
|
translate_all(capsys, root)
|
|
write(root / "docs" / "tools.md", TOOLS.replace('title="server.py"', 'title="app.py"'))
|
|
|
|
code, _, err = run(capsys, root, "stage", "--lang", "ja")
|
|
|
|
staged = staged_page(root, "tools.md")
|
|
assert (code, err, staged.split("\n")[2]) == (0, "", '!!! note "英語版より古い翻訳"')
|
|
assert ('title="server.py"' in staged, 'title="app.py"' in staged) == (True, False)
|
|
|
|
assert run(capsys, root, "translate", "--lang", "ja", translator=FakeTranslator([TOOLS_JA]))[0] == 0
|
|
code, _, err = run(capsys, root, "stage", "--lang", "ja")
|
|
|
|
staged = staged_page(root, "tools.md")
|
|
assert (code, err, staged.split("\n")[2]) == (0, "", '??? note "機械翻訳"')
|
|
assert ('title="server.py"' in staged, 'title="app.py"' in staged) == (False, True)
|
|
|
|
|
|
def test_notice_links_climb_from_the_staged_page_to_the_english_page_and_this_sites_translations_page() -> None:
|
|
"""Tool-defined: links are written relative to the page's source path, as the renderer reads them: the
|
|
English page is the same path one site up (out of the page's directory, then out of the language site)
|
|
and the translations page is this site's own `translations.md`, so no link names a host or a path prefix
|
|
and a mirrored copy of the sites keeps working."""
|
|
notice = t.Notice('The "outdated" one', "Compare the [English page](ENGLISH_PAGE); see [why](TRANSLATIONS_PAGE).")
|
|
|
|
assert t.render_notice(notice, "outdated", "servers/deep/page.md") == snapshot("""\
|
|
!!! note "The 'outdated' one"
|
|
|
|
Compare the [English page](../../../servers/deep/page/); see [why](../../translations.md).\
|
|
""")
|
|
assert t.render_notice(notice, "translated", "servers/index.md") == snapshot("""\
|
|
??? note "The 'outdated' one"
|
|
|
|
Compare the [English page](../../servers/); see [why](../translations.md).\
|
|
""")
|
|
|
|
|
|
def test_stage_that_stops_part_way_leaves_no_titles_file_from_an_earlier_run(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: `titles.json` tells `build_config.py --lang` the tree beside it is complete, so a run
|
|
that fails midway (here an excluded page vanished from `docs/`) must not leave the previous run's behind."""
|
|
root = make_repo(tmp_path)
|
|
assert run(capsys, root, "stage", "--lang", "ja")[0] == 0
|
|
titles = root / ".build" / "i18n" / "ja" / "titles.json"
|
|
listed_before = titles.is_file()
|
|
(root / "docs" / "migration.md").unlink()
|
|
|
|
code, out, err = run(capsys, root, "stage", "--lang", "ja")
|
|
|
|
assert (listed_before, code, out, titles.exists()) == (True, 2, "", False)
|
|
assert err.startswith(f"translations: cannot read {root / 'docs' / 'migration.md'}: ") # then the OS's wording
|
|
|
|
|
|
def test_stage_without_lang_stages_every_language_in_the_registry(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Tool-defined: one invocation assembles every language's tree (the site build's single staging pass);
|
|
a language with no prompt inputs yet still stages, in English."""
|
|
root = make_repo(tmp_path)
|
|
write(
|
|
root / "i18n" / "languages.yml", LANGUAGES + " - code: ko\n name: 한국어\n theme: ko\n hreflang: ko\n"
|
|
)
|
|
|
|
code, out, err = run(capsys, root, "stage")
|
|
|
|
assert (code, out, err) == (0, "staged ja at .build/i18n/ja/docs\nstaged ko at .build/i18n/ko/docs\n", "")
|
|
listed = [(root / ".build" / "i18n" / code / "titles.json").is_file() for code in ("ja", "ko")]
|
|
assert listed == [True, True]
|