Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot]
23caa2709b 📝 Update release notes
[skip ci]
2025-12-25 11:02:01 +00:00
Sebastián Ramírez
c264467efe 📝 Add documentary to website (#14600) 2025-12-25 11:01:37 +00:00
github-actions[bot]
2b212ddd76 📝 Update release notes
[skip ci]
2025-12-24 10:28:45 +00:00
Nils-Hero Lindemann
7203e860b3 🌐 Update translations for de (update-outdated) (#14581)
* Sync with #14575 (Drop support for Pydantic v1)

* Add a word and fix a typo

Found while syncing.
2025-12-24 11:28:19 +01:00
15 changed files with 41 additions and 870 deletions

View File

@@ -120,6 +120,12 @@ The key features are:
---
## FastAPI mini documentary
There's a <a href="https://www.youtube.com/watch?v=mpR8ngthqiE" class="external-link" target="_blank">FastAPI mini documentary</a> released at the end of 2025, you can watch it online:
<a href="https://www.youtube.com/watch?v=mpR8ngthqiE" target="_blank"><img src="https://fastapi.tiangolo.com/img/fastapi-documentary.jpg" alt="FastAPI Mini Documentary"></a>
## **Typer**, the FastAPI of CLIs
<a href="https://typer.tiangolo.com" target="_blank"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg" style="width: 20%;"></a>

View File

@@ -48,7 +48,7 @@ Sie können die verwendeten Zeilen aus dem Docstring einer *Pfadoperation-Funkti
Das Hinzufügen eines `\f` (ein maskiertes „Form Feed“-Zeichen) führt dazu, dass **FastAPI** die für OpenAPI verwendete Ausgabe an dieser Stelle abschneidet.
Sie wird nicht in der Dokumentation angezeigt, aber andere Tools (z. B. Sphinx) können den Rest verwenden.
Sie wird nicht in der Dokumentation angezeigt, aber andere Tools (wie z. B. Sphinx) können den Rest verwenden.
{* ../../docs_src/path_operation_advanced_configuration/tutorial004_py310.py hl[17:27] *}
@@ -153,48 +153,16 @@ Und Sie könnten dies auch tun, wenn der Datentyp im Request nicht JSON ist.
In der folgenden Anwendung verwenden wir beispielsweise weder die integrierte Funktionalität von FastAPI zum Extrahieren des JSON-Schemas aus Pydantic-Modellen noch die automatische Validierung für JSON. Tatsächlich deklarieren wir den Request-Content-Type als YAML und nicht als JSON:
//// tab | Pydantic v2
{* ../../docs_src/path_operation_advanced_configuration/tutorial007_py39.py hl[15:20, 22] *}
////
//// tab | Pydantic v1
{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py hl[15:20, 22] *}
////
/// info | Info
In Pydantic Version 1 hieß die Methode zum Abrufen des JSON-Schemas für ein Modell `Item.schema()`, in Pydantic Version 2 heißt die Methode `Item.model_json_schema()`.
///
Obwohl wir nicht die standardmäßig integrierte Funktionalität verwenden, verwenden wir dennoch ein Pydantic-Modell, um das JSON-Schema für die Daten, die wir in YAML empfangen möchten, manuell zu generieren.
Dann verwenden wir den Request direkt und extrahieren den Body als `bytes`. Das bedeutet, dass FastAPI nicht einmal versucht, den Request-Payload als JSON zu parsen.
Dann verwenden wir den Request direkt und extrahieren den Body als `bytes`. Das bedeutet, dass FastAPI nicht einmal versucht, die Request-Payload als JSON zu parsen.
Und dann parsen wir in unserem Code diesen YAML-Inhalt direkt und verwenden dann wieder dasselbe Pydantic-Modell, um den YAML-Inhalt zu validieren:
//// tab | Pydantic v2
{* ../../docs_src/path_operation_advanced_configuration/tutorial007_py39.py hl[24:31] *}
////
//// tab | Pydantic v1
{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py hl[24:31] *}
////
/// info | Info
In Pydantic Version 1 war die Methode zum Parsen und Validieren eines Objekts `Item.parse_obj()`, in Pydantic Version 2 heißt die Methode `Item.model_validate()`.
///
/// tip | Tipp
Hier verwenden wir dasselbe Pydantic-Modell wieder.

View File

@@ -60,24 +60,8 @@ Auf die gleiche Weise wie bei Pydantic-Modellen deklarieren Sie Klassenattribute
Sie können dieselben Validierungs-Funktionen und -Tools verwenden, die Sie für Pydantic-Modelle verwenden, z. B. verschiedene Datentypen und zusätzliche Validierungen mit `Field()`.
//// tab | Pydantic v2
{* ../../docs_src/settings/tutorial001_py39.py hl[2,5:8,11] *}
////
//// tab | Pydantic v1
/// info | Info
In Pydantic v1 würden Sie `BaseSettings` direkt von `pydantic` statt von `pydantic_settings` importieren.
///
{* ../../docs_src/settings/tutorial001_pv1_py39.py hl[2,5:8,11] *}
////
/// tip | Tipp
Für ein schnelles Copy-and-paste verwenden Sie nicht dieses Beispiel, sondern das letzte unten.
@@ -215,8 +199,6 @@ APP_NAME="ChimichangApp"
Und dann aktualisieren Sie Ihre `config.py` mit:
//// tab | Pydantic v2
{* ../../docs_src/settings/app03_an_py39/config.py hl[9] *}
/// tip | Tipp
@@ -225,26 +207,6 @@ Das Attribut `model_config` wird nur für die Pydantic-Konfiguration verwendet.
///
////
//// tab | Pydantic v1
{* ../../docs_src/settings/app03_an_py39/config_pv1.py hl[9:10] *}
/// tip | Tipp
Die Klasse `Config` wird nur für die Pydantic-Konfiguration verwendet. Weitere Informationen finden Sie unter <a href="https://docs.pydantic.dev/1.10/usage/model_config/" class="external-link" target="_blank">Pydantic Model Config</a>.
///
////
/// info | Info
In Pydantic Version 1 erfolgte die Konfiguration in einer internen Klasse `Config`, in Pydantic Version 2 erfolgt sie in einem Attribut `model_config`. Dieses Attribut akzeptiert ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr>. Um automatische Codevervollständigung und Inline-Fehlerberichte zu erhalten, können Sie `SettingsConfigDict` importieren und verwenden, um dieses `dict` zu definieren.
///
Hier definieren wir die Konfiguration `env_file` innerhalb Ihrer Pydantic-`Settings`-Klasse und setzen den Wert auf den Dateinamen mit der dotenv-Datei, die wir verwenden möchten.
### Die `Settings` nur einmal laden mittels `lru_cache` { #creating-the-settings-only-once-with-lru-cache }

View File

@@ -2,21 +2,23 @@
Wenn Sie eine ältere FastAPI-App haben, nutzen Sie möglicherweise Pydantic Version 1.
FastAPI unterstützt seit Version 0.100.0 sowohl Pydantic v1 als auch v2.
FastAPI Version 0.100.0 unterstützte sowohl Pydantic v1 als auch v2. Es verwendete, was auch immer Sie installiert hatten.
Wenn Sie Pydantic v2 installiert hatten, wurde dieses verwendet. Wenn stattdessen Pydantic v1 installiert war, wurde jenes verwendet.
FastAPI Version 0.119.0 führte eine teilweise Unterstützung für Pydantic v1 innerhalb von Pydantic v2 (als `pydantic.v1`) ein, um die Migration zu v2 zu erleichtern.
Pydantic v1 ist jetzt deprecatet und die Unterstützung dafür wird in den nächsten Versionen von FastAPI entfernt, Sie sollten also zu **Pydantic v2 migrieren**. Auf diese Weise erhalten Sie die neuesten Features, Verbesserungen und Fixes.
FastAPI 0.126.0 entfernte die Unterstützung für Pydantic v1, während `pydantic.v1` noch eine Weile unterstützt wurde.
/// warning | Achtung
Außerdem hat das Pydantic-Team die Unterstützung für Pydantic v1 in den neuesten Python-Versionen eingestellt, beginnend mit **Python 3.14**.
Das Pydantic-Team hat die Unterstützung für Pydantic v1 in den neuesten Python-Versionen eingestellt, beginnend mit **Python 3.14**.
Dies schließt `pydantic.v1` ein, das unter Python 3.14 und höher nicht mehr unterstützt wird.
Wenn Sie die neuesten Features von Python nutzen möchten, müssen Sie sicherstellen, dass Sie Pydantic v2 verwenden.
///
Wenn Sie eine ältere FastAPI-App mit Pydantic v1 haben, zeige ich Ihnen hier, wie Sie sie zu Pydantic v2 migrieren, und die **neuen Features in FastAPI 0.119.0**, die Ihnen bei einer schrittweisen Migration helfen.
Wenn Sie eine ältere FastAPI-App mit Pydantic v1 haben, zeige ich Ihnen hier, wie Sie sie zu Pydantic v2 migrieren, und die **Features in FastAPI 0.119.0**, die Ihnen bei einer schrittweisen Migration helfen.
## Offizieller Leitfaden { #official-guide }
@@ -44,7 +46,7 @@ Danach können Sie die Tests ausführen und prüfen, ob alles funktioniert. Fall
## Pydantic v1 in v2 { #pydantic-v1-in-v2 }
Pydantic v2 enthält alles aus Pydantic v1 als Untermodul `pydantic.v1`.
Pydantic v2 enthält alles aus Pydantic v1 als Untermodul `pydantic.v1`. Dies wird aber in Versionen oberhalb von Python 3.13 nicht mehr unterstützt.
Das bedeutet, Sie können die neueste Version von Pydantic v2 installieren und die alten Pydanticv1Komponenten aus diesem Untermodul importieren und verwenden, als hätten Sie das alte Pydantic v1 installiert.

View File

@@ -1,6 +1,6 @@
# Separate OpenAPI-Schemas für Eingabe und Ausgabe oder nicht { #separate-openapi-schemas-for-input-and-output-or-not }
Bei Verwendung von **Pydantic v2** ist die generierte OpenAPI etwas genauer und **korrekter** als zuvor. 😎
Seit der Veröffentlichung von **Pydantic v2** ist die generierte OpenAPI etwas genauer und **korrekter** als zuvor. 😎
Tatsächlich gibt es in einigen Fällen sogar **zwei JSON-Schemas** in OpenAPI für dasselbe Pydantic-Modell, für Eingabe und Ausgabe, je nachdem, ob sie **Defaultwerte** haben.
@@ -100,5 +100,3 @@ Und jetzt wird es ein einziges Schema für die Eingabe und Ausgabe des Modells g
<div class="screenshot">
<img src="/img/tutorial/separate-openapi-schemas/image05.png">
</div>
Dies ist das gleiche Verhalten wie in Pydantic v1. 🤓

View File

@@ -50,14 +50,6 @@ Wenn Sie Teil-Aktualisierungen entgegennehmen, ist der `exclude_unset`-Parameter
Wie in `item.model_dump(exclude_unset=True)`.
/// info | Info
In Pydantic v1 hieß diese Methode `.dict()`, in Pydantic v2 wurde sie <abbr title="veraltet, obsolet: Es soll nicht mehr verwendet werden">deprecatet</abbr> (aber immer noch unterstützt) und in `.model_dump()` umbenannt.
Die Beispiele hier verwenden `.dict()` für die Kompatibilität mit Pydantic v1, Sie sollten jedoch stattdessen `.model_dump()` verwenden, wenn Sie Pydantic v2 verwenden können.
///
Das wird ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr> erstellen, mit nur den Daten, die gesetzt wurden, als das `item`-Modell erstellt wurde, Defaultwerte ausgeschlossen.
Sie können das verwenden, um ein `dict` zu erstellen, das nur die (im <abbr title="Request Anfrage: Daten, die der Client zum Server sendet">Request</abbr>) gesendeten Daten enthält, ohne Defaultwerte:
@@ -68,14 +60,6 @@ Sie können das verwenden, um ein `dict` zu erstellen, das nur die (im <abbr tit
Jetzt können Sie eine Kopie des existierenden Modells mittels `.model_copy()` erstellen, wobei Sie dem `update`-Parameter ein `dict` mit den zu ändernden Daten übergeben.
/// info | Info
In Pydantic v1 hieß diese Methode `.copy()`, in Pydantic v2 wurde sie <abbr title="veraltet, obsolet: Es soll nicht mehr verwendet werden">deprecatet</abbr> (aber immer noch unterstützt) und in `.model_copy()` umbenannt.
Die Beispiele hier verwenden `.copy()` für die Kompatibilität mit Pydantic v1, Sie sollten jedoch stattdessen `.model_copy()` verwenden, wenn Sie Pydantic v2 verwenden können.
///
Wie in `stored_item_model.model_copy(update=update_data)`:
{* ../../docs_src/body_updates/tutorial002_py310.py hl[33] *}

View File

@@ -127,14 +127,6 @@ Innerhalb der Funktion können Sie alle Attribute des Modellobjekts direkt verwe
{* ../../docs_src/body/tutorial002_py310.py *}
/// info | Info
In Pydantic v1 hieß die Methode `.dict()`, sie wurde in Pydantic v2 deprecatet (aber weiterhin unterstützt) und in `.model_dump()` umbenannt.
Die Beispiele hier verwenden `.dict()` zur Kompatibilität mit Pydantic v1, aber Sie sollten stattdessen `.model_dump()` verwenden, wenn Sie Pydantic v2 nutzen können.
///
## Requestbody- + Pfad-Parameter { #request-body-path-parameters }
Sie können Pfad-Parameter und den Requestbody gleichzeitig deklarieren.

View File

@@ -22,21 +22,13 @@ Hier ist eine allgemeine Idee, wie die Modelle mit ihren Passwortfeldern aussehe
{* ../../docs_src/extra_models/tutorial001_py310.py hl[7,9,14,20,22,27:28,31:33,38:39] *}
/// info | Info
### Über `**user_in.model_dump()` { #about-user-in-model-dump }
In Pydantic v1 hieß die Methode `.dict()`, in Pydantic v2 wurde sie <abbr title="veraltet, obsolet: Es soll nicht mehr verwendet werden">deprecatet</abbr> (aber weiterhin unterstützt) und in `.model_dump()` umbenannt.
Die Beispiele hier verwenden `.dict()` für die Kompatibilität mit Pydantic v1, aber Sie sollten `.model_dump()` verwenden, wenn Sie Pydantic v2 verwenden können.
///
### Über `**user_in.dict()` { #about-user-in-dict }
#### Die `.dict()`-Methode von Pydantic { #pydantics-dict }
#### Pydantics `.model_dump()` { #pydantics-model-dump }
`user_in` ist ein Pydantic-Modell der Klasse `UserIn`.
Pydantic-Modelle haben eine `.dict()`-Methode, die ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr> mit den Daten des Modells zurückgibt.
Pydantic-Modelle haben eine `.model_dump()`-Methode, die ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr> mit den Daten des Modells zurückgibt.
Wenn wir also ein Pydantic-Objekt `user_in` erstellen, etwa so:
@@ -47,7 +39,7 @@ user_in = UserIn(username="john", password="secret", email="john.doe@example.com
und dann aufrufen:
```Python
user_dict = user_in.dict()
user_dict = user_in.model_dump()
```
haben wir jetzt ein `dict` mit den Daten in der Variablen `user_dict` (es ist ein `dict` statt eines Pydantic-Modellobjekts).
@@ -103,20 +95,20 @@ UserInDB(
#### Ein Pydantic-Modell aus dem Inhalt eines anderen { #a-pydantic-model-from-the-contents-of-another }
Da wir im obigen Beispiel `user_dict` von `user_in.dict()` bekommen haben, wäre dieser Code:
Da wir im obigen Beispiel `user_dict` von `user_in.model_dump()` bekommen haben, wäre dieser Code:
```Python
user_dict = user_in.dict()
user_dict = user_in.model_dump()
UserInDB(**user_dict)
```
gleichwertig zu:
```Python
UserInDB(**user_in.dict())
UserInDB(**user_in.model_dump())
```
... weil `user_in.dict()` ein `dict` ist, und dann lassen wir Python es „entpacken“, indem wir es an `UserInDB` mit vorangestelltem `**` übergeben.
... weil `user_in.model_dump()` ein `dict` ist, und dann lassen wir Python es „entpacken“, indem wir es an `UserInDB` mit vorangestelltem `**` übergeben.
Auf diese Weise erhalten wir ein Pydantic-Modell aus den Daten eines anderen Pydantic-Modells.
@@ -125,7 +117,7 @@ Auf diese Weise erhalten wir ein Pydantic-Modell aus den Daten eines anderen Pyd
Und dann fügen wir das zusätzliche Schlüsselwort-Argument `hashed_password=hashed_password` hinzu, wie in:
```Python
UserInDB(**user_in.dict(), hashed_password=hashed_password)
UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
```
... was so ist wie:
@@ -180,7 +172,6 @@ Wenn Sie eine <a href="https://docs.pydantic.dev/latest/concepts/types/#unions"
{* ../../docs_src/extra_models/tutorial003_py310.py hl[1,14:15,18:20,33] *}
### `Union` in Python 3.10 { #union-in-python-3-10 }
In diesem Beispiel übergeben wir `Union[PlaneItem, CarItem]` als Wert des Arguments `response_model`.
@@ -203,7 +194,6 @@ Dafür verwenden Sie Pythons Standard-`typing.List` (oder nur `list` in Python 3
{* ../../docs_src/extra_models/tutorial004_py39.py hl[18] *}
## Response mit beliebigem `dict` { #response-with-arbitrary-dict }
Sie können auch eine Response deklarieren, die ein beliebiges `dict` zurückgibt, indem Sie nur die Typen der Schlüssel und Werte ohne ein Pydantic-Modell deklarieren.
@@ -214,7 +204,6 @@ In diesem Fall können Sie `typing.Dict` verwenden (oder nur `dict` in Python 3.
{* ../../docs_src/extra_models/tutorial005_py39.py hl[6] *}
## Zusammenfassung { #recap }
Verwenden Sie gerne mehrere Pydantic-Modelle und vererben Sie je nach Bedarf.

View File

@@ -205,20 +205,6 @@ Wenn Sie sich mit all diesen **„regulärer Ausdruck“**-Ideen verloren fühle
Aber nun wissen Sie, dass Sie sie in **FastAPI** immer dann verwenden können, wenn Sie sie brauchen.
### Pydantic v1 `regex` statt `pattern` { #pydantic-v1-regex-instead-of-pattern }
Vor Pydantic Version 2 und FastAPI 0.100.0, hieß der Parameter `regex` statt `pattern`, aber das ist jetzt obsolet.
Sie könnten immer noch Code sehen, der den alten Namen verwendet:
//// tab | Pydantic v1
{* ../../docs_src/query_params_str_validations/tutorial004_regex_an_py310.py hl[11] *}
////
Beachten Sie aber, dass das obsolet ist und auf den neuen Parameter `pattern` aktualisiert werden sollte. 🤓
## Defaultwerte { #default-values }
Natürlich können Sie Defaultwerte verwenden, die nicht `None` sind.

View File

@@ -252,20 +252,6 @@ Wenn Sie also den Artikel mit der ID `foo` bei der *Pfadoperation* anfragen, wir
/// info | Info
In Pydantic v1 hieß diese Methode `.dict()`, in Pydantic v2 wurde sie <abbr title="veraltet, obsolet: Es soll nicht mehr verwendet werden">deprecatet</abbr> (aber immer noch unterstützt) und in `.model_dump()` umbenannt.
Die Beispiele hier verwenden `.dict()` für die Kompatibilität mit Pydantic v1, Sie sollten jedoch stattdessen `.model_dump()` verwenden, wenn Sie Pydantic v2 verwenden können.
///
/// info | Info
FastAPI verwendet `.dict()` von Pydantic Modellen, <a href="https://docs.pydantic.dev/1.10/usage/exporting_models/#modeldict" class="external-link" target="_blank">mit dessen `exclude_unset`-Parameter</a>, um das zu erreichen.
///
/// info | Info
Sie können auch:
* `response_model_exclude_defaults=True`

View File

@@ -8,36 +8,14 @@ Hier sind mehrere Möglichkeiten, das zu tun.
Sie können `examples` („Beispiele“) für ein Pydantic-Modell deklarieren, welche dem generierten JSON-Schema hinzugefügt werden.
//// tab | Pydantic v2
{* ../../docs_src/schema_extra_example/tutorial001_py310.py hl[13:24] *}
////
//// tab | Pydantic v1
{* ../../docs_src/schema_extra_example/tutorial001_pv1_py310.py hl[13:23] *}
////
Diese zusätzlichen Informationen werden unverändert zum für dieses Modell ausgegebenen **JSON-Schema** hinzugefügt und in der API-Dokumentation verwendet.
//// tab | Pydantic v2
In Pydantic Version 2 würden Sie das Attribut `model_config` verwenden, das ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr> akzeptiert, wie beschrieben in <a href="https://docs.pydantic.dev/latest/api/config/" class="external-link" target="_blank">Pydantic-Dokumentation: Configuration</a>.
Sie können das Attribut `model_config` verwenden, das ein <abbr title="Dictionary Zuordnungstabelle: In anderen Sprachen auch Hash, Map, Objekt, Assoziatives Array genannt">`dict`</abbr> akzeptiert, wie beschrieben in <a href="https://docs.pydantic.dev/latest/api/config/" class="external-link" target="_blank">Pydantic-Dokumentation: Configuration</a>.
Sie können `json_schema_extra` setzen, mit einem `dict`, das alle zusätzlichen Daten enthält, die im generierten JSON-Schema angezeigt werden sollen, einschließlich `examples`.
////
//// tab | Pydantic v1
In Pydantic Version 1 würden Sie eine interne Klasse `Config` und `schema_extra` verwenden, wie beschrieben in <a href="https://docs.pydantic.dev/1.10/usage/schema/#schema-customization" class="external-link" target="_blank">Pydantic-Dokumentation: Schema customization</a>.
Sie können `schema_extra` setzen, mit einem `dict`, das alle zusätzlichen Daten enthält, die im generierten JSON-Schema angezeigt werden sollen, einschließlich `examples`.
////
/// tip | Tipp
Mit derselben Technik können Sie das JSON-Schema erweitern und Ihre eigenen benutzerdefinierten Zusatzinformationen hinzufügen.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -117,6 +117,12 @@ The key features are:
---
## FastAPI mini documentary { #fastapi-mini-documentary }
There's a <a href="https://www.youtube.com/watch?v=mpR8ngthqiE" class="external-link" target="_blank">FastAPI mini documentary</a> released at the end of 2025, you can watch it online:
<a href="https://www.youtube.com/watch?v=mpR8ngthqiE" target="_blank"><img src="https://fastapi.tiangolo.com/img/fastapi-documentary.jpg" alt="FastAPI Mini Documentary"></a>
## **Typer**, the FastAPI of CLIs { #typer-the-fastapi-of-clis }
<a href="https://typer.tiangolo.com" target="_blank"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg" style="width: 20%;"></a>

View File

@@ -7,6 +7,14 @@ hide:
## Latest Changes
### Docs
* 📝 Add documentary to website. PR [#14600](https://github.com/fastapi/fastapi/pull/14600) by [@tiangolo](https://github.com/tiangolo).
### Translations
* 🌐 Update translations for de (update-outdated). PR [#14581](https://github.com/fastapi/fastapi/pull/14581) by [@nilslindemann](https://github.com/nilslindemann).
### Internal
* 👷 Update secrets check. PR [#14592](https://github.com/fastapi/fastapi/pull/14592) by [@tiangolo](https://github.com/tiangolo).

View File

@@ -1,694 +0,0 @@
import os
import platform
import re
import subprocess
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Literal, cast
import typer
ROOT = Path("../") # assuming this script is in the scripts directory
DOCS_ROOT = os.getenv("DOCS_ROOT", "docs")
TMP_DOCS_PATH = os.getenv("TMP_DOCS_PATH", "non-git/translations")
VSCODE_COMMAND = os.getenv(
"VSCODE_COMMAND", "code.cmd" if platform.system() == "Windows" else "code"
)
# TBD: `Literal` is not supported in typer 0.16.0, which is the
# version given in the requirements-docs.txt.
# Shall we upgrade that requirement to 0.20.0?
LANGS = Literal["es", "de", "ru", "pt", "uk", "fr"]
non_translated_sections = (
f"reference{os.sep}",
"release-notes.md",
"fastapi-people.md",
"external-links.md",
"newsletter.md",
"management-tasks.md",
"management.md",
"contributing.md",
)
class Retry(Exception):
pass
class CompareError(Exception):
pass
@dataclass
class Config:
lang: LANGS
interactive: bool = True
check_code_includes: bool = True
check_multiline_blocks: bool = True
check_headers_and_permalinks: bool = True
check_markdown_links: bool = True
check_html_links: bool = True
full_paths: bool = False
# ===================================================================================
# Code includes
CODE_INCLUDE_RE = re.compile(r"^\{\*\s*(\S+)\s*(.*)\*\}$")
def extract_code_includes(lines: list[str]) -> list[tuple[str, str, str, int]]:
includes = []
for line_no, line in enumerate(lines, start=1):
if CODE_INCLUDE_RE.match(line):
includes.append((line_no, line))
return includes
def replace_code_includes(source_text: str, target_text: str) -> str:
target_lines = target_text.splitlines()
source_code_includes = extract_code_includes(source_text.splitlines())
target_code_includes = extract_code_includes(target_lines)
if len(source_code_includes) != len(target_code_includes):
raise CompareError(
f"Number of code includes differs: "
f"{len(source_code_includes)} in source vs {len(target_code_includes)} in target."
)
for src_include, tgt_include in zip(source_code_includes, target_code_includes):
_, src_line = src_include
tgt_line_no, _ = tgt_include
target_lines[tgt_line_no - 1] = src_line
target_lines.append("") # To preserve the empty line in the end of the file
return "\n".join(target_lines)
# ===================================================================================
# Multiline code blocks
LANG_RE = re.compile(r"^```([\w-]*)", re.MULTILINE)
def get_code_block_lang(line: str) -> str:
match = LANG_RE.match(line)
if match:
return match.group(1)
return ""
def extract_multiline_blocks(text: str) -> list[tuple[str, int, str]]:
lines = text.splitlines()
blocks = []
in_code_block3 = False
in_code_block4 = False
current_block_lang = ""
current_block_start_line = -1
current_block_lines = []
for line_no, line in enumerate(lines, start=1):
stripped = line.lstrip()
# --- Detect opening fence ---
if not (in_code_block3 or in_code_block4):
if stripped.startswith("```"):
current_block_start_line = line_no
count = len(stripped) - len(stripped.lstrip("`"))
if count == 3:
in_code_block3 = True
current_block_lang = get_code_block_lang(stripped)
current_block_lines = [line]
continue
elif count >= 4:
in_code_block4 = True
current_block_lang = get_code_block_lang(stripped)
current_block_lines = [line]
continue
# --- Detect closing fence ---
elif in_code_block3:
if stripped.startswith("```"):
count = len(stripped) - len(stripped.lstrip("`"))
if count == 3:
current_block_lines.append(line)
blocks.append(
(
current_block_lang,
current_block_start_line,
"\n".join(current_block_lines),
)
)
in_code_block3 = False
current_block_lang = ""
current_block_start_line = -1
continue
current_block_lines.append(line)
elif in_code_block4:
if stripped.startswith("````"):
count = len(stripped) - len(stripped.lstrip("`"))
if count >= 4:
current_block_lines.append(line)
blocks.append(
(
current_block_lang,
current_block_start_line,
"\n".join(current_block_lines),
)
)
in_code_block4 = False
current_block_lang = ""
current_block_start_line = -1
continue
current_block_lines.append(line)
return blocks
def replace_blocks(source_text: str, target_text: str) -> str:
source_blocks = extract_multiline_blocks(source_text)
target_blocks = extract_multiline_blocks(target_text)
if len(source_blocks) != len(target_blocks):
raise CompareError(
f"Number of code blocks differs: "
f"{len(source_blocks)} in source vs {len(target_blocks)} in target."
)
for i, ((src_lang, *_), (tgt_lang, tgt_line_no, *_)) in enumerate(
zip(source_blocks, target_blocks), 1
):
if src_lang != tgt_lang:
raise CompareError(
f"Type mismatch in block #{i} (line {tgt_line_no}): "
f"'{src_lang or '(no lang)'}' vs '{tgt_lang or '(no lang)'}'"
)
# Sequentially replace each block in target with the one from source
result = target_text
for (*_, src_block), (*_, tgt_block) in zip(source_blocks, target_blocks):
result = result.replace(tgt_block, src_block, 1)
return result
# ===================================================================================
# Headers and permalinks
header_with_permalink_pattern = re.compile(r"^(#{1,6}) (.+?)(\s*\{\s*#.*\s*\})?\s*$")
def extract_headers_and_permalinks(lines: list[str]) -> list[tuple[str, int, str]]:
headers = []
in_code_block3 = False
in_code_block4 = False
for line_no, line in enumerate(lines, start=1):
if not (in_code_block3 or in_code_block4):
if line.startswith("```"):
count = len(line) - len(line.lstrip("`"))
if count == 3:
in_code_block3 = True
continue
elif count >= 4:
in_code_block4 = True
continue
header_match = header_with_permalink_pattern.match(line)
if header_match:
hashes, _title, permalink = header_match.groups()
headers.append((hashes, line_no, permalink))
elif in_code_block3:
if line.startswith("```"):
count = len(line) - len(line.lstrip("`"))
if count == 3:
in_code_block3 = False
continue
elif in_code_block4:
if line.startswith("````"):
count = len(line) - len(line.lstrip("`"))
if count >= 4:
in_code_block4 = False
continue
return headers
def replace_headers_and_permalinks(source_text: str, target_text: str) -> str:
target_lines = target_text.splitlines()
source_headers = extract_headers_and_permalinks(source_text.splitlines())
target_headers = extract_headers_and_permalinks(target_lines)
if len(source_headers) != len(target_headers):
raise CompareError(
f"Number of headers differs: "
f"{len(source_headers)} in source vs {len(target_headers)} in target."
)
for i, ((src_hashes, *_), (tgt_hashes, tgt_line_no, *_)) in enumerate(
zip(source_headers, target_headers), 1
):
if src_hashes != tgt_hashes:
raise CompareError(
f"Header level mismatch in #{i} (line {tgt_line_no}): "
"'{src_hashes}' vs '{tgt_hashes}'"
)
# Sequentially replace each header permalink in target with the one from source
for src_header, tgt_header in zip(source_headers, target_headers):
src_permalink = src_header[2]
tgt_line_no = tgt_header[1] - 1 # Convert from 1-based to 0-based
header_match = header_with_permalink_pattern.match(target_lines[tgt_line_no])
if header_match:
hashes, title, _ = header_match.groups()
target_lines[tgt_line_no] = (
f"{hashes} {title}{src_permalink or ' (ERROR - MISSING PERMALINK)'}"
)
target_lines.append("") # To preserve the empty line in the end of the file
return "\n".join(target_lines)
# ===================================================================================
# Links
MARKDOWN_LINK_RE = re.compile(
r"(?<!\!)" # not an image ![...]
r"\[(?P<text>.*?)\]" # link text (non-greedy)
r"\("
r"(?P<url>\S+?)" # url (no spaces, non-greedy)
r'(?:\s+["\'](?P<title>.*?)["\'])?' # optional title in "" or ''
r"\)"
)
def extract_markdown_links(lines: list[str]) -> list[tuple[str, int]]:
links = []
for line_no, line in enumerate(lines, start=1):
for m in MARKDOWN_LINK_RE.finditer(line):
url = m.group("url")
links.append((url, line_no))
return links
def replace_markdown_links(source_text: str, target_text: str, lang: str) -> str:
target_lines = target_text.splitlines()
source_links = extract_markdown_links(source_text.splitlines())
target_links = extract_markdown_links(target_lines)
if len(source_links) != len(target_links):
raise CompareError(
f"Number of markdown links differs: "
f"{len(source_links)} in source vs {len(target_links)} in target."
)
# Sequentially replace each link URL in target with the one from source
for (src_link, _), (tgt_link, tgt_line_no) in zip(source_links, target_links):
real_line_no = tgt_line_no - 1 # Convert to zero-based
line = target_lines[real_line_no]
link_replace = add_lang_code_if_needed(src_link, tgt_link, lang)
target_lines[real_line_no] = line.replace(tgt_link, link_replace)
target_lines.append("") # To preserve the empty line in the end of the file
return "\n".join(target_lines)
HTML_LINK_RE = re.compile(r"<a\s+[^>]*>.*?</a>")
HTML_LINK_TEXT = re.compile(r"<a\b([^>]*)>(.*?)</a>")
HTML_LINK_OPEN_TAG_RE = re.compile(r"<a\b([^>]*)>")
HTML_ATTR_RE = re.compile(r'(\w+)\s*=\s*([\'"])(.*?)\2')
def extract_html_links(
lines: list[str],
) -> list[tuple[tuple[str, list[tuple[str, str, str]], str], int]]:
links = []
for line_no, line in enumerate(lines, start=1):
for html_link in HTML_LINK_RE.finditer(line):
link_str = html_link.group(0)
link_text = cast(re.Match, HTML_LINK_TEXT.match(link_str)).group(2)
link_data = (link_str, [], link_text)
link_open_tag = cast(re.Match, HTML_LINK_OPEN_TAG_RE.match(link_str)).group(
1
)
attributes = re.findall(HTML_ATTR_RE, link_open_tag)
for attr_data in attributes:
link_data[1].append(attr_data)
links.append((link_data, line_no))
return links
TIANGOLO_COM = "https://fastapi.tiangolo.com"
def add_lang_code_if_needed(url: str, prev_url: str, lang_code: str) -> str:
if url.startswith(TIANGOLO_COM):
if prev_url.startswith(f"{TIANGOLO_COM}/{lang_code}"):
url = url.replace(TIANGOLO_COM, f"{TIANGOLO_COM}/{lang_code}")
return url
def reconstruct_html_link(
attributes: list[tuple[str, str, str]],
link_text: str,
prev_attributes: list[tuple[str, str, str]],
lang_code: str,
) -> str:
prev_attributes_dict = {attr[0]: attr[2] for attr in prev_attributes}
prev_url = prev_attributes_dict["href"]
attributes_upd = []
for attr_name, attr_quotes, attr_value in attributes:
if attr_name == "href":
attr_value = add_lang_code_if_needed(attr_value, prev_url, lang_code)
attributes_upd.append((attr_name, attr_quotes, attr_value))
attrs_str = " ".join(
f"{name}={quetes}{value}{quetes}" for name, quetes, value in attributes_upd
)
return f"<a {attrs_str}>{link_text}</a>"
def replace_html_links(source_text: str, target_text: str, lang: str) -> str:
target_lines = target_text.splitlines()
source_links = extract_html_links(source_text.splitlines())
target_links = extract_html_links(target_lines)
if len(source_links) != len(target_links):
raise CompareError(
f"Number of HTML links differs: "
f"{len(source_links)} in source vs {len(target_links)} in target."
)
# Sequentially replace attributes of each link URL in target with the one from source
for (src_link_data, _), (tgt_link_data, tgt_line_no) in zip(
source_links, target_links
):
real_line_no = tgt_line_no - 1 # Convert to zero-based
line = target_lines[real_line_no]
tgt_link_text = tgt_link_data[2]
tgt_link_original = tgt_link_data[0]
tgt_link_override = reconstruct_html_link(
src_link_data[1], tgt_link_text, tgt_link_data[1], lang
)
target_lines[real_line_no] = line.replace(tgt_link_original, tgt_link_override)
target_lines.append("") # To preserve the empty line in the end of the file
return "\n".join(target_lines)
# ===================================================================================
# Images
# ===================================================================================
# Helper functions
def get_lang_doc_root_dir(lang: str) -> Path:
return ROOT / DOCS_ROOT / lang / "docs"
def iter_all_lang_paths(lang_path_root: Path) -> Iterable[Path]:
"""
Iterate on the markdown files to translate in order of priority.
"""
first_dirs = [
lang_path_root / "learn",
lang_path_root / "tutorial",
lang_path_root / "advanced",
lang_path_root / "about",
lang_path_root / "how-to",
]
first_parent = lang_path_root
yield from first_parent.glob("*.md")
for dir_path in first_dirs:
yield from dir_path.rglob("*.md")
first_dirs_str = tuple(str(d) for d in first_dirs)
for path in lang_path_root.rglob("*.md"):
if str(path).startswith(first_dirs_str):
continue
if path.parent == first_parent:
continue
yield path
def get_all_paths(lang: str):
res: list[str] = []
lang_docs_root = get_lang_doc_root_dir(lang)
for path in iter_all_lang_paths(lang_docs_root):
relpath = path.relative_to(lang_docs_root)
if not str(relpath).startswith(non_translated_sections):
res.append(str(relpath))
return res
# ===================================================================================
# Main
def process_one_file_with_retry(document_path: str, config: Config) -> bool:
en_docs_root_path = Path(get_lang_doc_root_dir("en"))
lang_docs_root_path = Path(get_lang_doc_root_dir(config.lang))
while True:
try:
return process_one_file(
en_docs_root_path / document_path,
lang_docs_root_path / document_path,
config=config,
)
except Retry: # Retry is only raised in interactive mode
pass
def process_one_file(
en_doc_path_str: Path, lang_doc_path_str: Path, config: Config
) -> bool:
en_doc_path = Path(en_doc_path_str)
lang_doc_path = Path(lang_doc_path_str)
if not en_doc_path.exists():
print(
f"{'❌🔎 ' if config.interactive else ''}{en_doc_path_str} - doesn't exist"
)
return False
en_doc_text = en_doc_path.read_text(encoding="utf-8")
lang_doc_text = lang_doc_path.read_text(encoding="utf-8")
lang_doc_text_orig = lang_doc_text
try:
if config.check_code_includes:
lang_doc_text = replace_code_includes(
source_text=en_doc_text,
target_text=lang_doc_text,
)
if config.check_multiline_blocks:
lang_doc_text = replace_blocks(
source_text=en_doc_text,
target_text=lang_doc_text,
)
if config.check_headers_and_permalinks:
lang_doc_text = replace_headers_and_permalinks(
source_text=en_doc_text,
target_text=lang_doc_text,
)
if config.check_markdown_links:
lang_doc_text = replace_markdown_links(
source_text=en_doc_text,
target_text=lang_doc_text,
lang=config.lang,
)
if config.check_html_links:
lang_doc_text = replace_html_links(
source_text=en_doc_text,
target_text=lang_doc_text,
lang=config.lang,
)
except CompareError as e:
print(f"{'❔❌ ' if config.interactive else ''}{lang_doc_path_str} Error: {e}")
if not config.interactive:
return False
subprocess.run([VSCODE_COMMAND, "--diff", lang_doc_path_str, en_doc_path_str])
resp = ""
while resp not in ("f", "e"):
resp = input(
" Check the diff, fix the problem, and then type F if it's fixed or E to mark as invalid and skip: "
)
if resp.lower() == "e":
print(f"{lang_doc_path_str} skipped with error")
return
print(f"Check {lang_doc_path_str} again")
raise Retry() from None
if lang_doc_text_orig != lang_doc_text:
print(
f"{'❔🆚 ' if config.interactive else ''}{lang_doc_path_str} - non-empty diff"
)
if not config.interactive:
return False
tmp_path = ROOT / TMP_DOCS_PATH / Path(lang_doc_path_str)
tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(lang_doc_text, encoding="utf-8")
subprocess.run(
[VSCODE_COMMAND, "--diff", str(lang_doc_path_str), str(tmp_path)]
)
resp = ""
while resp not in ("f", "e"):
resp = input(
" Check the diff, fix the problem, and then type F to mark it as fixed or E to to mark as invalid and skip: "
).lower()
if resp == "e":
print(f"{lang_doc_path_str} skipped with non-empty diff")
return
print(f"{'' if config.interactive else ''}{lang_doc_path_str} - Ok")
return True
# ===================================================================================
# Typer app
cli = typer.Typer()
@cli.callback()
def callback():
pass
@cli.callback()
def main(
ctx: typer.Context,
lang: Annotated[LANGS, typer.Option()],
interactive: Annotated[
bool,
typer.Option(
help="If True, will open VSCode diffs for each change to fix and confirm.",
),
] = True,
full_paths: Annotated[
bool,
typer.Option(
help="If True, the provided document paths are treated as full paths.",
),
] = False,
check_code_includes: Annotated[
bool,
typer.Option(
help="If True, will compare code includes blocks.",
),
] = True,
check_multiline_blocks: Annotated[
bool,
typer.Option(
help="If True, will compare multiline code blocks.",
),
] = True,
check_headers_and_permalinks: Annotated[
bool,
typer.Option(
help="If True, will compare headers and permalinks.",
),
] = True,
check_markdown_links: Annotated[
bool,
typer.Option(
help="If True, will compare markdown links.",
),
] = True,
check_html_links: Annotated[
bool,
typer.Option(
help="If True, will compare HTML links.",
),
] = True,
):
ctx.obj = Config(
lang=lang,
interactive=interactive,
full_paths=full_paths,
check_code_includes=check_code_includes,
check_multiline_blocks=check_multiline_blocks,
check_headers_and_permalinks=check_headers_and_permalinks,
check_markdown_links=check_markdown_links,
check_html_links=check_html_links,
)
@cli.command()
def process_all(
ctx: typer.Context,
):
"""
Go through all documents of language and compare special blocks with the corresponding
blocks in English versions of those documents.
"""
config = cast(Config, ctx.obj)
lang_docs_root_path = get_lang_doc_root_dir(config.lang)
docs = get_all_paths(config.lang)
all_good = True
pages_with_errors: list[str] = []
for doc in docs:
res = process_one_file_with_retry(document_path=doc, config=config)
all_good = all_good and res
if not res:
pages_with_errors.append(doc)
if not all_good:
print("Some documents had errors:")
docs_path = lang_docs_root_path.relative_to(ROOT)
for page in pages_with_errors:
print(f" - {docs_path / page}")
raise typer.Exit(code=1)
@cli.command()
def process_pages(
doc_paths: Annotated[
list[str],
typer.Argument(
help="List of relative paths to the EN documents. Should be relative to docs/en/docs/",
),
],
ctx: typer.Context,
):
"""
Compare special blocks of specified EN documents with the corresponding blocks in
translated versions of those documents.
"""
config = cast(Config, ctx.obj)
lang_docs_root_path = get_lang_doc_root_dir(config.lang)
all_good = True
pages_with_errors: list[str] = []
for doc_path in doc_paths:
if config.full_paths:
path = ROOT / doc_path.lstrip("/")
doc_path = str(path.relative_to(lang_docs_root_path))
res = process_one_file_with_retry(document_path=doc_path, config=config)
all_good = all_good and res
if not res:
pages_with_errors.append(doc_path)
if not all_good:
print("Some documents had errors:")
docs_path = lang_docs_root_path.relative_to(ROOT)
for page in pages_with_errors:
print(f" - {docs_path / page}")
raise typer.Exit(code=1)
if __name__ == "__main__":
cli()