Skip to content

Commit 677244f

Browse files
Daniel Jonesclaude
andcommitted
refactor(v3-languages): collapse fetch + generate into one script
- Folds scripts/generate_language_table.py into scripts/fetch_v3_languages.py. One script now fetches every /v3/languages response and regenerates the inline RESOURCES block in the snippet (or skips the snippet via --no-snippet). The dedicated workflow step is gone; the cron just runs the fetcher. - Renames snippets/language-table.jsx to snippets/supported-languages.jsx so the filename matches the only component it exports. Updates the two MDX imports and the workflow add-paths to follow. - Discovers per-resource files from data/v3-languages/*.json instead of from a hardcoded list, so new resources (and the manual translation_memory.json) appear in the snippet automatically. - Adds retry-with-backoff for transient API failures: 429 and 5xx statuses plus network errors retry up to five times, honouring Retry-After when the server sets it. Other HTTP errors raise as before. - Validates response schemas before writing them: /resources must be a list of objects with non-empty string `name`; each per-resource response must be a list of objects with non-empty string `lang` and `name`. A schema mismatch raises with the exact path so a workflow failure is actionable. - Hard-fails (instead of warning + continuing) when the snippet file or its BEGIN/END GENERATED markers are missing. - Trims data/v3-languages/README.md down to the data layout; script flags and behaviour now live in the script's module docstring and --help output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 45b5b95 commit 677244f

7 files changed

Lines changed: 373 additions & 288 deletions

File tree

‎.github/workflows/refresh-v3-languages.yml‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,11 @@ jobs:
2323
- name: Checkout
2424
uses: actions/checkout@v5
2525

26-
- name: Fetch latest /v3/languages responses
26+
- name: Fetch latest /v3/languages responses and refresh snippet
2727
env:
2828
DEEPL_AUTH_KEY: ${{ secrets.DEEPL_API_KEY }}
2929
run: python3 scripts/fetch_v3_languages.py
3030

31-
- name: Regenerate snippets/language-table.jsx from vended data
32-
run: python3 scripts/generate_language_table.py
33-
3431
- name: Open or update refresh PR
3532
uses: peter-evans/create-pull-request@v6
3633
with:
@@ -40,7 +37,7 @@ jobs:
4037
title: "chore(v3-languages): refresh vended responses"
4138
body: |
4239
Automated refresh of `data/v3-languages/` from `https://api.deepl.com/v3/languages`,
43-
with `snippets/language-table.jsx` regenerated from the new responses. Opened by
40+
with `snippets/supported-languages.jsx` regenerated from the new responses. Opened by
4441
the `refresh-v3-languages` workflow.
4542
4643
This PR is rewritten in place on each run: if the vended responses change again
@@ -51,6 +48,6 @@ jobs:
5148
closed and the branch deleted automatically.
5249
add-paths: |
5350
data/v3-languages/**
54-
snippets/language-table.jsx
51+
snippets/supported-languages.jsx
5552
delete-branch: true
5653
labels: automated, v3-languages

‎api-reference/improve-text.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ public: true
44
sidebarTitle: "Overview"
55
---
66

7-
import { SupportedLanguages } from "/snippets/language-table.jsx"
7+
import { SupportedLanguages } from "/snippets/supported-languages.jsx"
88

99
<Info>
1010
**Introducing DeepL API for Write**

‎data/v3-languages/README.md‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,15 @@ This directory holds verbatim JSON responses from the DeepL `/v3/languages` endp
77
| File | Endpoint |
88
|---|---|
99
| `resources.json` | `GET /v3/languages/resources` |
10-
| `translate_text.json` | `GET /v3/languages?resource=translate_text&include=beta&include=external` |
11-
| `translate_document.json` | `GET /v3/languages?resource=translate_document&include=beta&include=external` |
12-
| `voice.json` | `GET /v3/languages?resource=voice&include=beta&include=external` |
13-
| `write.json` | `GET /v3/languages?resource=write&include=beta&include=external` |
14-
| `glossary.json` | `GET /v3/languages?resource=glossary&include=beta&include=external` |
15-
| `style_rules.json` | `GET /v3/languages?resource=style_rules&include=beta&include=external` |
10+
| `<resource>.json` | `GET /v3/languages?resource=<resource>&include=beta&include=external` |
1611

17-
Each per-resource file requests `include=beta&include=external` so the vended data is the full superset. Consumers filter on the `status` and per-feature `external` fields when they want a narrower view.
18-
19-
## Refreshing
20-
21-
Set `DEEPL_AUTH_KEY` in your environment, then run:
12+
where <resource> is one of the resources returned by `/resources` (`translate_text`, `translate_document`,
13+
`voice`, `write`, etc.).
2214

23-
```sh
24-
python3 scripts/fetch_v3_languages.py
25-
```
26-
27-
Flags:
15+
Each per-resource file requests `include=beta&include=external` so the vended data is the full superset. Consumers filter on the `status` and per-feature `external` fields when they want a narrower view.
2816

29-
- `--free` — hit `https://api-free.deepl.com` instead of the Pro endpoint.
30-
- `--base-url <url>` — point at any other host (staging, mock, local server). Also configurable via the `DEEPL_API_BASE_URL` environment variable.
17+
These files are refreshed hourly by the [`refresh-v3-languages`](../../.github/workflows/refresh-v3-languages.yml) GitHub Action, which runs [`scripts/fetch_v3_languages.py`](../../scripts/fetch_v3_languages.py) and opens a pull request whenever the API responses change. See that script's module docstring or `--help` for flags and behaviour (auth, alternate endpoints, manual local refresh).
3118

32-
The script overwrites every file in this directory.
19+
## `translation_memory.json`
3320

34-
A scheduled GitHub Action refreshes these files automatically and opens a pull request when the responses change; manual runs are only needed for local testing.
21+
Translation Memory is not yet exposed by `/v3/languages`, so `translation_memory.json` is currently maintained by hand in the shape of a `/v3/languages` response. The fetcher skips it; once the API exposes Translation Memory as a resource, the next refresh will overwrite the manual file with the real response and no other code has to change.

‎docs/getting-started/supported-languages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ sidebarTitle: "Languages supported"
66
mode: "wide"
77
---
88

9-
import { SupportedLanguages } from "/snippets/language-table.jsx"
9+
import { SupportedLanguages } from "/snippets/supported-languages.jsx"
1010

1111
The DeepL API supports the following languages. These can also be retrieved programmatically via the [`/v3/languages` endpoint](/api-reference/languages/retrieve-supported-languages-by-resource), which returns language support per resource along with feature availability (e.g. formality, glossary, auto-detection). The legacy [`/v2/languages` endpoint](/api-reference/languages/retrieve-supported-languages) is also available but deprecated.
1212

‎scripts/fetch_v3_languages.py‎

Lines changed: 180 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,76 @@
11
#!/usr/bin/env python3
2-
"""Fetch v3/languages responses and write them to data/v3-languages/.
2+
"""Fetch /v3/languages responses and refresh the vended copies in this repo.
33
4-
Reads DEEPL_AUTH_KEY from the environment. By default uses the Pro
5-
endpoint; pass --free to hit api-free.deepl.com, or --base-url to point
6-
at any other host (e.g. a staging environment or local mock for
7-
testing). The DEEPL_API_BASE_URL environment variable does the same.
4+
What this script does, in order:
85
9-
The fetcher requests every resource with ?include=beta&include=external so
10-
the vended files contain the full superset of languages and features.
11-
Consumers filter client-side using the status field on languages/features.
6+
1. Calls `GET /v3/languages/resources` and, for every resource, calls
7+
`GET /v3/languages?resource=<name>&include=beta&include=external`.
8+
2. Writes each response verbatim to `data/v3-languages/<name>.json`
9+
(plus `resources.json`). These files are the single source of truth
10+
that the rest of the repo reads.
11+
3. Regenerates the inline `RESOURCES` object in
12+
`snippets/supported-languages.jsx` between its `// BEGIN GENERATED` /
13+
`// END GENERATED` markers so the `<SupportedLanguages>` snippet
14+
stays in sync with the vended JSON. Mintlify's snippet sandbox does
15+
not honour ES module imports, so the data has to ship inline.
16+
17+
Auth and endpoint:
18+
19+
- Reads `DEEPL_AUTH_KEY` from the environment (required).
20+
- Defaults to the Pro endpoint (`https://api.deepl.com`).
21+
- `--free` switches to `https://api-free.deepl.com`.
22+
- `--base-url <url>` (or `DEEPL_API_BASE_URL`) points at any other host,
23+
e.g. a staging environment or a local mock for testing. `--base-url`
24+
and `--free` are mutually exclusive.
25+
26+
Other flags:
27+
28+
- `--out <dir>` overrides the JSON output directory (defaults to
29+
`data/v3-languages/` relative to this repo).
30+
- `--no-snippet` skips step 3 when you only want to refresh the JSON.
31+
32+
The hourly `refresh-v3-languages` GitHub Action runs this script and
33+
opens a pull request whenever the responses change. Manual runs are
34+
only needed for local testing or after editing the script itself.
1235
"""
1336
from __future__ import annotations
1437

1538
import argparse
1639
import json
1740
import os
1841
import sys
42+
import time
1943
import urllib.error
2044
import urllib.parse
2145
import urllib.request
2246
from pathlib import Path
2347

2448
INCLUDES = ("beta", "external")
25-
DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "v3-languages"
49+
REPO_ROOT = Path(__file__).resolve().parent.parent
50+
DATA_DIR = REPO_ROOT / "data" / "v3-languages"
51+
SNIPPET = REPO_ROOT / "snippets" / "supported-languages.jsx"
2652
PRO_URL = "https://api.deepl.com"
2753
FREE_URL = "https://api-free.deepl.com"
54+
BEGIN_MARKER = " // BEGIN GENERATED: RESOURCES (do not edit; run scripts/fetch_v3_languages.py)"
55+
END_MARKER = " // END GENERATED"
56+
57+
# Retry config for transient failures (429s, 5xx, network errors).
58+
MAX_RETRIES = 5
59+
INITIAL_BACKOFF_S = 1.0
60+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
61+
62+
def _retry_delay(attempt: int, retry_after: str | None) -> float:
63+
"""Honour Retry-After (seconds) when present; otherwise exponential backoff."""
64+
if retry_after:
65+
try:
66+
return max(0.0, float(retry_after))
67+
except ValueError:
68+
# HTTP-date form is rare; fall through to backoff.
69+
pass
70+
return INITIAL_BACKOFF_S * (2 ** attempt)
2871

2972

30-
def get(url: str, key: str) -> object:
73+
def http_get_json(url: str, key: str) -> object:
3174
req = urllib.request.Request(
3275
url,
3376
headers={
@@ -36,36 +79,138 @@ def get(url: str, key: str) -> object:
3679
"User-Agent": "deepl-api-docs-vendor/1.0",
3780
},
3881
)
39-
with urllib.request.urlopen(req, timeout=30) as resp:
40-
return json.loads(resp.read())
82+
for attempt in range(MAX_RETRIES + 1):
83+
try:
84+
with urllib.request.urlopen(req, timeout=30) as resp:
85+
return json.loads(resp.read())
86+
except urllib.error.HTTPError as e:
87+
if e.code in RETRYABLE_STATUSES and attempt < MAX_RETRIES:
88+
delay = _retry_delay(attempt, e.headers.get("Retry-After"))
89+
print(
90+
f"retry {attempt + 1}/{MAX_RETRIES}: {e.code} {e.reason} from {url} "
91+
f"(sleeping {delay:.1f}s)",
92+
file=sys.stderr,
93+
)
94+
time.sleep(delay)
95+
continue
96+
raise
97+
except urllib.error.URLError as e:
98+
# Network-level failure (connection reset, DNS, timeout, etc.).
99+
if attempt < MAX_RETRIES:
100+
delay = _retry_delay(attempt, None)
101+
print(
102+
f"retry {attempt + 1}/{MAX_RETRIES}: network error {e.reason} "
103+
f"from {url} (sleeping {delay:.1f}s)",
104+
file=sys.stderr,
105+
)
106+
time.sleep(delay)
107+
continue
108+
raise
109+
# Unreachable: loop either returns or raises.
110+
raise RuntimeError("retry loop exited unexpectedly")
41111

42112

43-
def fetch(base_url: str, key: str) -> dict[str, object]:
44-
resources = get(f"{base_url}/v3/languages/resources", key)
113+
def _require_str(value: object, path: str) -> str:
114+
if not isinstance(value, str) or not value:
115+
raise RuntimeError(f"unexpected schema at {path}: want non-empty string, got {value!r}")
116+
return value
117+
118+
119+
def _validate_resources(resources: object) -> list[dict]:
45120
if not isinstance(resources, list):
46-
raise RuntimeError(f"Unexpected resources payload: {resources!r}")
121+
raise RuntimeError(f"unexpected /v3/languages/resources payload: want list, got {type(resources).__name__}")
122+
validated: list[dict] = []
123+
for i, entry in enumerate(resources):
124+
if not isinstance(entry, dict):
125+
raise RuntimeError(f"unexpected schema at resources[{i}]: want object, got {type(entry).__name__}")
126+
_require_str(entry.get("name"), f"resources[{i}].name")
127+
validated.append(entry)
128+
return validated
129+
130+
131+
def _validate_languages(payload: object, resource: str) -> list[dict]:
132+
if not isinstance(payload, list):
133+
raise RuntimeError(
134+
f"unexpected /v3/languages?resource={resource} payload: "
135+
f"want list, got {type(payload).__name__}"
136+
)
137+
for i, entry in enumerate(payload):
138+
if not isinstance(entry, dict):
139+
raise RuntimeError(
140+
f"unexpected schema at {resource}[{i}]: want object, got {type(entry).__name__}"
141+
)
142+
_require_str(entry.get("lang"), f"{resource}[{i}].lang")
143+
_require_str(entry.get("name"), f"{resource}[{i}].name")
144+
return payload
145+
47146

48-
out: dict[str, object] = {"resources.json": resources}
147+
def fetch(base_url: str, key: str) -> dict[str, object]:
148+
resources = _validate_resources(
149+
http_get_json(f"{base_url}/v3/languages/resources", key)
150+
)
151+
152+
payloads: dict[str, object] = {"resources.json": resources}
49153
qs = urllib.parse.urlencode([("include", v) for v in INCLUDES], doseq=True)
50154
for entry in resources:
51155
name = entry["name"]
52-
langs = get(f"{base_url}/v3/languages?resource={name}&{qs}", key)
53-
out[f"{name}.json"] = langs
54-
return out
156+
langs = _validate_languages(
157+
http_get_json(f"{base_url}/v3/languages?resource={name}&{qs}", key),
158+
name,
159+
)
160+
payloads[f"{name}.json"] = langs
161+
return payloads
55162

56163

57-
def write_files(payloads: dict[str, object], out_dir: Path) -> list[Path]:
164+
def write_json_files(payloads: dict[str, object], out_dir: Path) -> list[Path]:
58165
out_dir.mkdir(parents=True, exist_ok=True)
59166
written: list[Path] = []
60167
for filename, payload in payloads.items():
61168
path = out_dir / filename
62169
with path.open("w", encoding="utf-8") as f:
63-
json.dump(payload, f, indent=2, ensure_ascii=False, sort_keys=False)
170+
json.dump(payload, f, indent=2, ensure_ascii=False)
64171
f.write("\n")
65172
written.append(path)
66173
return written
67174

68175

176+
def render_resources_block(data_dir: Path) -> str:
177+
"""Build the `const RESOURCES = {...}` literal from every per-resource
178+
JSON file in `data_dir`. `resources.json` (the index) is skipped; every
179+
other `*.json` file is treated as a resource response, including manual
180+
files like `translation_memory.json`.
181+
"""
182+
lines = [BEGIN_MARKER, " const RESOURCES = {"]
183+
for path in sorted(data_dir.glob("*.json")):
184+
if path.stem == "resources":
185+
continue
186+
entries = json.loads(path.read_text(encoding="utf-8"))
187+
lines.append(f' {json.dumps(path.stem)}: [')
188+
for entry in entries:
189+
lines.append(f" {json.dumps(entry, ensure_ascii=False)},")
190+
lines.append(" ],")
191+
lines.append(" }")
192+
lines.append(END_MARKER)
193+
return "\n".join(lines)
194+
195+
196+
def update_snippet(data_dir: Path) -> bool:
197+
if not SNIPPET.exists():
198+
raise SystemExit(f"error: snippet not found at {SNIPPET}")
199+
text = SNIPPET.read_text(encoding="utf-8")
200+
if BEGIN_MARKER not in text or END_MARKER not in text:
201+
raise SystemExit(
202+
f"error: {SNIPPET} is missing the BEGIN/END GENERATED markers"
203+
)
204+
new_block = render_resources_block(data_dir)
205+
start = text.index(BEGIN_MARKER)
206+
end = text.index(END_MARKER, start) + len(END_MARKER)
207+
updated = text[:start] + new_block + text[end:]
208+
if updated == text:
209+
return False
210+
SNIPPET.write_text(updated, encoding="utf-8")
211+
return True
212+
213+
69214
def resolve_base_url(cli_base_url: str | None, free: bool) -> str:
70215
if cli_base_url and free:
71216
raise SystemExit("error: --base-url and --free are mutually exclusive")
@@ -96,7 +241,12 @@ def main() -> int:
96241
"--out",
97242
type=Path,
98243
default=DATA_DIR,
99-
help=f"Output directory (default: {DATA_DIR})",
244+
help=f"Output directory for the vended JSON (default: {DATA_DIR})",
245+
)
246+
parser.add_argument(
247+
"--no-snippet",
248+
action="store_true",
249+
help="Skip regenerating snippets/supported-languages.jsx",
100250
)
101251
args = parser.parse_args()
102252

@@ -112,9 +262,16 @@ def main() -> int:
112262
print(f"error: {e.code} {e.reason} from {e.url}", file=sys.stderr)
113263
return 1
114264

115-
paths = write_files(payloads, args.out)
265+
paths = write_json_files(payloads, args.out)
116266
for p in paths:
117267
print(p)
268+
269+
if not args.no_snippet:
270+
if update_snippet(args.out):
271+
print(SNIPPET)
272+
else:
273+
print(f"{SNIPPET}: up to date")
274+
118275
return 0
119276

120277

0 commit comments

Comments
 (0)