diff --git a/.github/labeler.yml b/.github/labeler.yml
index c5b1f84f3..cdaefbf2d 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -17,6 +17,7 @@ lang-all:
- docs/*/docs/**
- all-globs-to-all-files:
- '!docs/en/docs/**'
+ - '!docs/*/**/_*.md'
- '!fastapi/**'
- '!pyproject.toml'
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index f78b6730e..73e1c6b67 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -21,7 +21,7 @@ jobs:
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
# For pull requests it's not necessary to checkout the code but for the main branch it is
- uses: dorny/paths-filter@v3
id: filter
@@ -32,12 +32,9 @@ jobs:
- docs/**
- docs_src/**
- requirements-docs.txt
- - requirements-docs-insiders.txt
- pyproject.toml
- mkdocs.yml
- - mkdocs.insiders.yml
- - mkdocs.maybe-insiders.yml
- - mkdocs.no-insiders.yml
+ - mkdocs.env.yml
- .github/workflows/build-docs.yml
- .github/workflows/deploy-docs.yml
- scripts/mkdocs_hooks.py
@@ -48,7 +45,7 @@ jobs:
outputs:
langs: ${{ steps.show-langs.outputs.langs }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -63,12 +60,6 @@ jobs:
pyproject.toml
- name: Install docs extras
run: uv pip install -r requirements-docs.txt
- # Install MkDocs Material Insiders here just to put it in the cache for the rest of the steps
- - name: Install Material for MkDocs Insiders
- if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
- run: uv pip install -r requirements-docs-insiders.txt
- env:
- TOKEN: ${{ secrets.FASTAPI_MKDOCS_MATERIAL_INSIDERS }}
- name: Verify Docs
run: python ./scripts/docs.py verify-docs
- name: Export Language Codes
@@ -90,7 +81,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -105,11 +96,6 @@ jobs:
pyproject.toml
- name: Install docs extras
run: uv pip install -r requirements-docs.txt
- - name: Install Material for MkDocs Insiders
- if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
- run: uv pip install -r requirements-docs-insiders.txt
- env:
- TOKEN: ${{ secrets.FASTAPI_MKDOCS_MATERIAL_INSIDERS }}
- name: Update Languages
run: python ./scripts/docs.py update-languages
- uses: actions/cache@v4
diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml
index 7d5449c6a..2abd2fdcf 100644
--- a/.github/workflows/contributors.yml
+++ b/.github/workflows/contributors.yml
@@ -24,7 +24,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index aa4fd6b65..50662a190 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -23,7 +23,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/label-approved.yml b/.github/workflows/label-approved.yml
index e6ae3d963..7f16254db 100644
--- a/.github/workflows/label-approved.yml
+++ b/.github/workflows/label-approved.yml
@@ -20,7 +20,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/latest-changes.yml b/.github/workflows/latest-changes.yml
index 2fa832fab..b9e45ea62 100644
--- a/.github/workflows/latest-changes.yml
+++ b/.github/workflows/latest-changes.yml
@@ -24,6 +24,8 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
+ # pin to actions/checkout@v5 for compatibility with latest-changes
+ # Ref: https://github.com/actions/checkout/issues/2313
- uses: actions/checkout@v5
with:
# To allow latest-changes to commit to the main branch
@@ -34,7 +36,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
with:
limit-access-to-actor: true
- - uses: tiangolo/latest-changes@0.4.0
+ - uses: tiangolo/latest-changes@0.4.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
latest_changes_file: docs/en/docs/release-notes.md
diff --git a/.github/workflows/notify-translations.yml b/.github/workflows/notify-translations.yml
index 04beeb64e..971e6bbd8 100644
--- a/.github/workflows/notify-translations.yml
+++ b/.github/workflows/notify-translations.yml
@@ -28,7 +28,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/people.yml b/.github/workflows/people.yml
index f15b92137..9b35a3d7e 100644
--- a/.github/workflows/people.yml
+++ b/.github/workflows/people.yml
@@ -24,7 +24,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 000000000..fa0574d7d
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,88 @@
+name: pre-commit
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+
+env:
+ IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
+
+jobs:
+ pre-commit:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: echo "$GITHUB_CONTEXT"
+ - uses: actions/checkout@v5
+ name: Checkout PR for own repo
+ if: env.IS_FORK == 'false'
+ with:
+ # To be able to commit it needs more than the last commit
+ ref: ${{ github.head_ref }}
+ # A token other than the default GITHUB_TOKEN is needed to be able to trigger CI
+ token: ${{ secrets.PRE_COMMIT }}
+ # pre-commit lite ci needs the default checkout configs to work
+ - uses: actions/checkout@v5
+ name: Checkout PR for fork
+ if: env.IS_FORK == 'true'
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.14"
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ cache-dependency-glob: |
+ requirements**.txt
+ pyproject.toml
+ uv.lock
+ - name: Install Dependencies
+ run: |
+ uv venv
+ uv pip install -r requirements.txt
+ - name: Run pre-commit
+ id: precommit
+ run: |
+ # Fetch the base branch for comparison
+ git fetch origin ${{ github.base_ref }}
+ uvx pre-commit run --from-ref origin/${{ github.base_ref }} --to-ref HEAD --show-diff-on-failure
+ continue-on-error: true
+ - name: Commit and push changes
+ if: env.IS_FORK == 'false'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ if git diff --staged --quiet; then
+ echo "No changes to commit"
+ else
+ git commit -m "🎨 Auto format"
+ git push
+ fi
+ - uses: pre-commit-ci/lite-action@v1.1.0
+ if: env.IS_FORK == 'true'
+ with:
+ msg: 🎨 Auto format
+ - name: Error out on pre-commit errors
+ if: steps.precommit.outcome == 'failure'
+ run: exit 1
+
+ # https://github.com/marketplace/actions/alls-green#why
+ pre-commit-alls-green: # This job does nothing and is only used for the branch protection
+ if: always()
+ needs:
+ - pre-commit
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dump GitHub context
+ env:
+ GITHUB_CONTEXT: ${{ toJson(github) }}
+ run: echo "$GITHUB_CONTEXT"
+ - name: Decide whether the needed jobs succeeded or failed
+ uses: re-actors/alls-green@release/v1
+ with:
+ jobs: ${{ toJSON(needs) }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 441eb4560..6d9a00b49 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -20,7 +20,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/smokeshow.yml b/.github/workflows/smokeshow.yml
index eed5fbec0..84c743019 100644
--- a/.github/workflows/smokeshow.yml
+++ b/.github/workflows/smokeshow.yml
@@ -21,7 +21,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.9'
diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml
index 7d29469a5..8b0249001 100644
--- a/.github/workflows/sponsors.yml
+++ b/.github/workflows/sponsors.yml
@@ -24,7 +24,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test-redistribute.yml b/.github/workflows/test-redistribute.yml
index a44f0b681..653ab2a74 100644
--- a/.github/workflows/test-redistribute.yml
+++ b/.github/workflows/test-redistribute.yml
@@ -22,7 +22,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9c3e2218b..8157e364b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -23,7 +23,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -65,7 +65,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
@@ -111,7 +111,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.8'
diff --git a/.github/workflows/topic-repos.yml b/.github/workflows/topic-repos.yml
index 22b37d59d..41dabee1e 100644
--- a/.github/workflows/topic-repos.yml
+++ b/.github/workflows/topic-repos.yml
@@ -19,7 +19,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml
index a7fcf84df..6506b8e28 100644
--- a/.github/workflows/translate.yml
+++ b/.github/workflows/translate.yml
@@ -42,7 +42,7 @@ jobs:
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.gitignore b/.gitignore
index ef6364a9a..6016ffa59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ archive.zip
# macOS
.DS_Store
+
+# Ignore while the setup still depends on requirements.txt files
+uv.lock
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8e5eba4c4..8e6d93fb7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,25 +1,29 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
-default_language_version:
- python: python3.10
repos:
-- repo: https://github.com/pre-commit/pre-commit-hooks
+ - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- - id: check-added-large-files
- - id: check-toml
- - id: check-yaml
+ - id: check-added-large-files
+ - id: check-toml
+ - id: check-yaml
args:
- - --unsafe
- - id: end-of-file-fixer
- - id: trailing-whitespace
-- repo: https://github.com/astral-sh/ruff-pre-commit
+ - --unsafe
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.3
hooks:
- - id: ruff
+ - id: ruff
args:
- --fix
- - id: ruff-format
-ci:
- autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
- autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate
+ - id: ruff-format
+ - repo: local
+ hooks:
+ - id: local-script
+ language: unsupported
+ name: local script
+ entry: uv run ./scripts/docs.py add-permalinks-pages
+ args:
+ - --update-existing
+ files: ^docs/en/docs/.*\.md$
diff --git a/docs/en/docs/css/custom.css b/docs/en/docs/css/custom.css
index a38df772f..87111ff64 100644
--- a/docs/en/docs/css/custom.css
+++ b/docs/en/docs/css/custom.css
@@ -1,3 +1,18 @@
+/* Fira Code, including characters used by Rich output, like the "heavy right-pointing angle bracket ornament", not included in Google Fonts */
+@import url(https://cdn.jsdelivr.net/npm/firacode@6.2.0/distr/fira_code.css);
+/* Noto Color Emoji for emoji support with the same font everywhere */
+@import url(https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap);
+
+/* Override default code font in Material for MkDocs to Fira Code */
+:root {
+ --md-code-font: "Fira Code", monospace, "Noto Color Emoji";
+}
+
+/* Override default regular font in Material for MkDocs to include Noto Color Emoji */
+:root {
+ --md-text-font: "Roboto", "Noto Color Emoji";
+}
+
.termynal-comment {
color: #4a968f;
font-style: italic;
diff --git a/docs/en/docs/css/termynal.css b/docs/en/docs/css/termynal.css
index 8534f9102..a2564e286 100644
--- a/docs/en/docs/css/termynal.css
+++ b/docs/en/docs/css/termynal.css
@@ -20,7 +20,7 @@
/* font-size: 18px; */
font-size: 15px;
/* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */
- font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace;
+ font-family: var(--md-code-font-family), 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace;
border-radius: 4px;
padding: 75px 45px 35px;
position: relative;
diff --git a/docs/en/docs/deployment/cloud.md b/docs/en/docs/deployment/cloud.md
index bdba87bce..4f5c23e4b 100644
--- a/docs/en/docs/deployment/cloud.md
+++ b/docs/en/docs/deployment/cloud.md
@@ -4,7 +4,7 @@ You can use virtually **any cloud provider** to deploy your FastAPI application.
In most of the cases, the main cloud providers have guides to deploy FastAPI with them.
-## FastAPI Cloud
+## FastAPI Cloud { #fastapi-cloud }
**FastAPI Cloud** is built by the same author and team behind **FastAPI**.
diff --git a/docs/en/docs/how-to/authentication-error-status-code.md b/docs/en/docs/how-to/authentication-error-status-code.md
new file mode 100644
index 000000000..f9433e5dd
--- /dev/null
+++ b/docs/en/docs/how-to/authentication-error-status-code.md
@@ -0,0 +1,17 @@
+# Use Old 403 Authentication Error Status Codes { #use-old-403-authentication-error-status-codes }
+
+Before FastAPI version `0.122.0`, when the integrated security utilities returned an error to the client after a failed authentication, they used the HTTP status code `403 Forbidden`.
+
+Starting with FastAPI version `0.122.0`, they use the more appropriate HTTP status code `401 Unauthorized`, and return a sensible `WWW-Authenticate` header in the response, following the HTTP specifications, RFC 7235, RFC 9110.
+
+But if for some reason your clients depend on the old behavior, you can revert to it by overriding the method `make_not_authenticated_error` in your security classes.
+
+For example, you can create a subclass of `HTTPBearer` that returns a `403 Forbidden` error instead of the default `401 Unauthorized` error:
+
+{* ../../docs_src/authentication_error_status_code/tutorial001_an_py39.py hl[9:13] *}
+
+/// tip
+
+Notice that the function returns the exception instance, it doesn't raise it. The raising is done in the rest of the internal code.
+
+///
diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md
index c3ee858ae..4b2cb937d 100644
--- a/docs/en/docs/release-notes.md
+++ b/docs/en/docs/release-notes.md
@@ -9,6 +9,39 @@ hide:
### Docs
+* 💅 Update CSS to explicitly use emoji font. PR [#14415](https://github.com/fastapi/fastapi/pull/14415) by [@tiangolo](https://github.com/tiangolo).
+
+## 0.122.0
+
+### Fixes
+
+* 🐛 Use `401` status code in security classes when credentials are missing. PR [#13786](https://github.com/fastapi/fastapi/pull/13786) by [@YuriiMotov](https://github.com/YuriiMotov).
+ * If your code depended on these classes raising the old (less correct) `403` status code, check the new docs about how to override the classes, to use the same old behavior: [Use Old 403 Authentication Error Status Codes](https://fastapi.tiangolo.com/how-to/authentication-error-status-code/).
+
+### Internal
+
+* 🔧 Configure labeler to exclude files that start from underscore for `lang-all` label. PR [#14213](https://github.com/fastapi/fastapi/pull/14213) by [@YuriiMotov](https://github.com/YuriiMotov).
+* 👷 Add pre-commit config with local script for permalinks. PR [#14398](https://github.com/fastapi/fastapi/pull/14398) by [@tiangolo](https://github.com/tiangolo).
+* 💄 Use font Fira Code to fix display of Rich panels in docs in Windows. PR [#14387](https://github.com/fastapi/fastapi/pull/14387) by [@tiangolo](https://github.com/tiangolo).
+* 👷 Add custom pre-commit CI. PR [#14397](https://github.com/fastapi/fastapi/pull/14397) by [@tiangolo](https://github.com/tiangolo).
+* ⬆ Bump actions/checkout from 5 to 6. PR [#14381](https://github.com/fastapi/fastapi/pull/14381) by [@dependabot[bot]](https://github.com/apps/dependabot).
+* 👷 Upgrade `latest-changes` GitHub Action and pin `actions/checkout@v5`. PR [#14403](https://github.com/fastapi/fastapi/pull/14403) by [@svlandeg](https://github.com/svlandeg).
+* 🛠️ Add `add-permalinks` and `add-permalinks-page` to `scripts/docs.py`. PR [#14033](https://github.com/fastapi/fastapi/pull/14033) by [@YuriiMotov](https://github.com/YuriiMotov).
+* 🔧 Upgrade Material for MkDocs and remove insiders. PR [#14375](https://github.com/fastapi/fastapi/pull/14375) by [@tiangolo](https://github.com/tiangolo).
+
+## 0.121.3
+
+### Refactors
+
+* ♻️ Make the result of `Depends()` and `Security()` hashable, as a workaround for other tools interacting with these internal parts. PR [#14372](https://github.com/fastapi/fastapi/pull/14372) by [@tiangolo](https://github.com/tiangolo).
+
+### Upgrades
+
+* ⬆️ Bump Starlette to <`0.51.0`. PR [#14282](https://github.com/fastapi/fastapi/pull/14282) by [@musicinmybrain](https://github.com/musicinmybrain).
+
+### Docs
+
+* 📝 Add missing hash part. PR [#14369](https://github.com/fastapi/fastapi/pull/14369) by [@nilslindemann](https://github.com/nilslindemann).
* 📝 Fix typos in code comments. PR [#14364](https://github.com/fastapi/fastapi/pull/14364) by [@Edge-Seven](https://github.com/Edge-Seven).
* 📝 Add docs for using FastAPI Cloud. PR [#14359](https://github.com/fastapi/fastapi/pull/14359) by [@tiangolo](https://github.com/tiangolo).
diff --git a/docs/en/mkdocs.maybe-insiders.yml b/docs/en/mkdocs.env.yml
similarity index 78%
rename from docs/en/mkdocs.maybe-insiders.yml
rename to docs/en/mkdocs.env.yml
index 37fd9338e..c5f6e07d7 100644
--- a/docs/en/mkdocs.maybe-insiders.yml
+++ b/docs/en/mkdocs.env.yml
@@ -1,6 +1,5 @@
# Define this here and not in the main mkdocs.yml file because that one is auto
# updated and written, and the script would remove the env var
-INHERIT: !ENV [INSIDERS_FILE, '../en/mkdocs.no-insiders.yml']
markdown_extensions:
pymdownx.highlight:
linenums: !ENV [LINENUMS, false]
diff --git a/docs/en/mkdocs.insiders.yml b/docs/en/mkdocs.insiders.yml
deleted file mode 100644
index 8d6d26e17..000000000
--- a/docs/en/mkdocs.insiders.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-plugins:
- social:
- cards_layout_options:
- logo: ../en/docs/img/icon-white.svg
- typeset:
-markdown_extensions:
- material.extensions.preview:
- targets:
- include:
- - "*"
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml
index df47c6f9c..fd346a3d3 100644
--- a/docs/en/mkdocs.yml
+++ b/docs/en/mkdocs.yml
@@ -1,4 +1,4 @@
-INHERIT: ../en/mkdocs.maybe-insiders.yml
+INHERIT: ../en/mkdocs.env.yml
site_name: FastAPI
site_description: FastAPI framework, high performance, easy to learn, fast to code, ready for production
site_url: https://fastapi.tiangolo.com/
@@ -52,6 +52,10 @@ theme:
repo_name: fastapi/fastapi
repo_url: https://github.com/fastapi/fastapi
plugins:
+ social:
+ cards_layout_options:
+ logo: ../en/docs/img/icon-white.svg
+ typeset:
search: null
macros:
include_yaml:
@@ -211,6 +215,7 @@ nav:
- how-to/custom-docs-ui-assets.md
- how-to/configure-swagger-ui.md
- how-to/testing-database.md
+ - how-to/authentication-error-status-code.md
- Reference (Code API):
- reference/index.md
- reference/fastapi.md
@@ -253,6 +258,10 @@ nav:
- management.md
- release-notes.md
markdown_extensions:
+ material.extensions.preview:
+ targets:
+ include:
+ - "*"
abbr: null
attr_list: null
footnotes: null
diff --git a/docs_src/authentication_error_status_code/tutorial001_an.py b/docs_src/authentication_error_status_code/tutorial001_an.py
new file mode 100644
index 000000000..40678e858
--- /dev/null
+++ b/docs_src/authentication_error_status_code/tutorial001_an.py
@@ -0,0 +1,20 @@
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class HTTPBearer403(HTTPBearer):
+ def make_not_authenticated_error(self) -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated"
+ )
+
+
+CredentialsDep = Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer403())]
+
+
+@app.get("/me")
+def read_me(credentials: CredentialsDep):
+ return {"message": "You are authenticated", "token": credentials.credentials}
diff --git a/docs_src/authentication_error_status_code/tutorial001_an_py39.py b/docs_src/authentication_error_status_code/tutorial001_an_py39.py
new file mode 100644
index 000000000..7bbc2f717
--- /dev/null
+++ b/docs_src/authentication_error_status_code/tutorial001_an_py39.py
@@ -0,0 +1,21 @@
+from typing import Annotated
+
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+app = FastAPI()
+
+
+class HTTPBearer403(HTTPBearer):
+ def make_not_authenticated_error(self) -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated"
+ )
+
+
+CredentialsDep = Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer403())]
+
+
+@app.get("/me")
+def read_me(credentials: CredentialsDep):
+ return {"message": "You are authenticated", "token": credentials.credentials}
diff --git a/docs_src/security/tutorial003.py b/docs_src/security/tutorial003.py
index 4b324866f..ce7a71b68 100644
--- a/docs_src/security/tutorial003.py
+++ b/docs_src/security/tutorial003.py
@@ -60,7 +60,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
+ detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
diff --git a/docs_src/security/tutorial003_an.py b/docs_src/security/tutorial003_an.py
index 8fb40dd4a..1b7056a20 100644
--- a/docs_src/security/tutorial003_an.py
+++ b/docs_src/security/tutorial003_an.py
@@ -61,7 +61,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
+ detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
diff --git a/docs_src/security/tutorial003_an_py310.py b/docs_src/security/tutorial003_an_py310.py
index ced4a2fbc..4a2743f6f 100644
--- a/docs_src/security/tutorial003_an_py310.py
+++ b/docs_src/security/tutorial003_an_py310.py
@@ -60,7 +60,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
+ detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
diff --git a/docs_src/security/tutorial003_an_py39.py b/docs_src/security/tutorial003_an_py39.py
index 068a3933e..b396210c8 100644
--- a/docs_src/security/tutorial003_an_py39.py
+++ b/docs_src/security/tutorial003_an_py39.py
@@ -60,7 +60,7 @@ async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
+ detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
diff --git a/docs_src/security/tutorial003_py310.py b/docs_src/security/tutorial003_py310.py
index af935e997..081259b31 100644
--- a/docs_src/security/tutorial003_py310.py
+++ b/docs_src/security/tutorial003_py310.py
@@ -58,7 +58,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
+ detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
diff --git a/fastapi/__init__.py b/fastapi/__init__.py
index 0672423cf..3fbd7fc28 100644
--- a/fastapi/__init__.py
+++ b/fastapi/__init__.py
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
-__version__ = "0.121.2"
+__version__ = "0.122.0"
from starlette import status as status
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 4b69e39a1..1e92c1ba2 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -1,3 +1,4 @@
+import dataclasses
import inspect
from contextlib import AsyncExitStack, contextmanager
from copy import copy, deepcopy
@@ -428,7 +429,7 @@ def analyze_param(
if depends is not None and depends.dependency is None:
# Copy `depends` before mutating it
depends = copy(depends)
- depends.dependency = type_annotation
+ depends = dataclasses.replace(depends, dependency=type_annotation)
# Handle non-param type annotations like Request
if lenient_issubclass(
diff --git a/fastapi/params.py b/fastapi/params.py
index 6a58d5808..6d07df35e 100644
--- a/fastapi/params.py
+++ b/fastapi/params.py
@@ -762,13 +762,13 @@ class File(Form): # type: ignore[misc]
)
-@dataclass
+@dataclass(frozen=True)
class Depends:
dependency: Optional[Callable[..., Any]] = None
use_cache: bool = True
scope: Union[Literal["function", "request"], None] = None
-@dataclass
+@dataclass(frozen=True)
class Security(Depends):
scopes: Optional[Sequence[str]] = None
diff --git a/fastapi/security/api_key.py b/fastapi/security/api_key.py
index 496c815a7..81c7be10d 100644
--- a/fastapi/security/api_key.py
+++ b/fastapi/security/api_key.py
@@ -1,22 +1,52 @@
-from typing import Optional
+from typing import Optional, Union
from annotated_doc import Doc
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.security.base import SecurityBase
from starlette.exceptions import HTTPException
from starlette.requests import Request
-from starlette.status import HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
from typing_extensions import Annotated
class APIKeyBase(SecurityBase):
- @staticmethod
- def check_api_key(api_key: Optional[str], auto_error: bool) -> Optional[str]:
+ def __init__(
+ self,
+ location: APIKeyIn,
+ name: str,
+ description: Union[str, None],
+ scheme_name: Union[str, None],
+ auto_error: bool,
+ ):
+ self.auto_error = auto_error
+
+ self.model: APIKey = APIKey(
+ **{"in": location},
+ name=name,
+ description=description,
+ )
+ self.scheme_name = scheme_name or self.__class__.__name__
+
+ def make_not_authenticated_error(self) -> HTTPException:
+ """
+ The WWW-Authenticate header is not standardized for API Key authentication but
+ the HTTP specification requires that an error of 401 "Unauthorized" must
+ include a WWW-Authenticate header.
+
+ Ref: https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized
+
+ For this, this method sends a custom challenge `APIKey`.
+ """
+ return HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "APIKey"},
+ )
+
+ def check_api_key(self, api_key: Optional[str]) -> Optional[str]:
if not api_key:
- if auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ if self.auto_error:
+ raise self.make_not_authenticated_error()
return None
return api_key
@@ -100,17 +130,17 @@ class APIKeyQuery(APIKeyBase):
),
] = True,
):
- self.model: APIKey = APIKey(
- **{"in": APIKeyIn.query},
+ super().__init__(
+ location=APIKeyIn.query,
name=name,
+ scheme_name=scheme_name,
description=description,
+ auto_error=auto_error,
)
- self.scheme_name = scheme_name or self.__class__.__name__
- self.auto_error = auto_error
async def __call__(self, request: Request) -> Optional[str]:
api_key = request.query_params.get(self.model.name)
- return self.check_api_key(api_key, self.auto_error)
+ return self.check_api_key(api_key)
class APIKeyHeader(APIKeyBase):
@@ -188,17 +218,17 @@ class APIKeyHeader(APIKeyBase):
),
] = True,
):
- self.model: APIKey = APIKey(
- **{"in": APIKeyIn.header},
+ super().__init__(
+ location=APIKeyIn.header,
name=name,
+ scheme_name=scheme_name,
description=description,
+ auto_error=auto_error,
)
- self.scheme_name = scheme_name or self.__class__.__name__
- self.auto_error = auto_error
async def __call__(self, request: Request) -> Optional[str]:
api_key = request.headers.get(self.model.name)
- return self.check_api_key(api_key, self.auto_error)
+ return self.check_api_key(api_key)
class APIKeyCookie(APIKeyBase):
@@ -276,14 +306,14 @@ class APIKeyCookie(APIKeyBase):
),
] = True,
):
- self.model: APIKey = APIKey(
- **{"in": APIKeyIn.cookie},
+ super().__init__(
+ location=APIKeyIn.cookie,
name=name,
+ scheme_name=scheme_name,
description=description,
+ auto_error=auto_error,
)
- self.scheme_name = scheme_name or self.__class__.__name__
- self.auto_error = auto_error
async def __call__(self, request: Request) -> Optional[str]:
api_key = request.cookies.get(self.model.name)
- return self.check_api_key(api_key, self.auto_error)
+ return self.check_api_key(api_key)
diff --git a/fastapi/security/http.py b/fastapi/security/http.py
index 3a5985650..0d1bbba3a 100644
--- a/fastapi/security/http.py
+++ b/fastapi/security/http.py
@@ -1,6 +1,6 @@
import binascii
from base64 import b64decode
-from typing import Optional
+from typing import Dict, Optional
from annotated_doc import Doc
from fastapi.exceptions import HTTPException
@@ -10,7 +10,7 @@ from fastapi.security.base import SecurityBase
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from starlette.requests import Request
-from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
from typing_extensions import Annotated
@@ -76,10 +76,22 @@ class HTTPBase(SecurityBase):
description: Optional[str] = None,
auto_error: bool = True,
):
- self.model = HTTPBaseModel(scheme=scheme, description=description)
+ self.model: HTTPBaseModel = HTTPBaseModel(
+ scheme=scheme, description=description
+ )
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
+ def make_authenticate_headers(self) -> Dict[str, str]:
+ return {"WWW-Authenticate": f"{self.model.scheme.title()}"}
+
+ def make_not_authenticated_error(self) -> HTTPException:
+ return HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ headers=self.make_authenticate_headers(),
+ )
+
async def __call__(
self, request: Request
) -> Optional[HTTPAuthorizationCredentials]:
@@ -87,9 +99,7 @@ class HTTPBase(SecurityBase):
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ raise self.make_not_authenticated_error()
else:
return None
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
@@ -99,6 +109,8 @@ class HTTPBasic(HTTPBase):
"""
HTTP Basic authentication.
+ Ref: https://datatracker.ietf.org/doc/html/rfc7617
+
## Usage
Create an instance object and use that object as the dependency in `Depends()`.
@@ -185,36 +197,28 @@ class HTTPBasic(HTTPBase):
self.realm = realm
self.auto_error = auto_error
+ def make_authenticate_headers(self) -> Dict[str, str]:
+ if self.realm:
+ return {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
+ return {"WWW-Authenticate": "Basic"}
+
async def __call__( # type: ignore
self, request: Request
) -> Optional[HTTPBasicCredentials]:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
- if self.realm:
- unauthorized_headers = {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
- else:
- unauthorized_headers = {"WWW-Authenticate": "Basic"}
if not authorization or scheme.lower() != "basic":
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_401_UNAUTHORIZED,
- detail="Not authenticated",
- headers=unauthorized_headers,
- )
+ raise self.make_not_authenticated_error()
else:
return None
- invalid_user_credentials_exc = HTTPException(
- status_code=HTTP_401_UNAUTHORIZED,
- detail="Invalid authentication credentials",
- headers=unauthorized_headers,
- )
try:
data = b64decode(param).decode("ascii")
- except (ValueError, UnicodeDecodeError, binascii.Error):
- raise invalid_user_credentials_exc # noqa: B904
+ except (ValueError, UnicodeDecodeError, binascii.Error) as e:
+ raise self.make_not_authenticated_error() from e
username, separator, password = data.partition(":")
if not separator:
- raise invalid_user_credentials_exc
+ raise self.make_not_authenticated_error()
return HTTPBasicCredentials(username=username, password=password)
@@ -306,17 +310,12 @@ class HTTPBearer(HTTPBase):
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ raise self.make_not_authenticated_error()
else:
return None
if scheme.lower() != "bearer":
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN,
- detail="Invalid authentication credentials",
- )
+ raise self.make_not_authenticated_error()
else:
return None
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
@@ -326,6 +325,12 @@ class HTTPDigest(HTTPBase):
"""
HTTP Digest authentication.
+ **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI,
+ but it doesn't implement the full Digest scheme, you would need to to subclass it
+ and implement it in your code.
+
+ Ref: https://datatracker.ietf.org/doc/html/rfc7616
+
## Usage
Create an instance object and use that object as the dependency in `Depends()`.
@@ -408,17 +413,12 @@ class HTTPDigest(HTTPBase):
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ raise self.make_not_authenticated_error()
else:
return None
if scheme.lower() != "digest":
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN,
- detail="Invalid authentication credentials",
- )
+ raise self.make_not_authenticated_error()
else:
return None
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials)
diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py
index f8d97d762..b41b0f877 100644
--- a/fastapi/security/oauth2.py
+++ b/fastapi/security/oauth2.py
@@ -8,7 +8,7 @@ from fastapi.param_functions import Form
from fastapi.security.base import SecurityBase
from fastapi.security.utils import get_authorization_scheme_param
from starlette.requests import Request
-from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
# TODO: import from typing when deprecating Python 3.9
from typing_extensions import Annotated
@@ -377,13 +377,33 @@ class OAuth2(SecurityBase):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
+ def make_not_authenticated_error(self) -> HTTPException:
+ """
+ The OAuth 2 specification doesn't define the challenge that should be used,
+ because a `Bearer` token is not really the only option to authenticate.
+
+ But declaring any other authentication challenge would be application-specific
+ as it's not defined in the specification.
+
+ For practical reasons, this method uses the `Bearer` challenge by default, as
+ it's probably the most common one.
+
+ If you are implementing an OAuth2 authentication scheme other than the provided
+ ones in FastAPI (based on bearer tokens), you might want to override this.
+
+ Ref: https://datatracker.ietf.org/doc/html/rfc6749
+ """
+ return HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
async def __call__(self, request: Request) -> Optional[str]:
authorization = request.headers.get("Authorization")
if not authorization:
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ raise self.make_not_authenticated_error()
else:
return None
return authorization
@@ -491,11 +511,7 @@ class OAuth2PasswordBearer(OAuth2):
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_401_UNAUTHORIZED,
- detail="Not authenticated",
- headers={"WWW-Authenticate": "Bearer"},
- )
+ raise self.make_not_authenticated_error()
else:
return None
return param
@@ -601,11 +617,7 @@ class OAuth2AuthorizationCodeBearer(OAuth2):
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_401_UNAUTHORIZED,
- detail="Not authenticated",
- headers={"WWW-Authenticate": "Bearer"},
- )
+ raise self.make_not_authenticated_error()
else:
return None # pragma: nocover
return param
diff --git a/fastapi/security/open_id_connect_url.py b/fastapi/security/open_id_connect_url.py
index 5e99798e6..e574a56a8 100644
--- a/fastapi/security/open_id_connect_url.py
+++ b/fastapi/security/open_id_connect_url.py
@@ -5,7 +5,7 @@ from fastapi.openapi.models import OpenIdConnect as OpenIdConnectModel
from fastapi.security.base import SecurityBase
from starlette.exceptions import HTTPException
from starlette.requests import Request
-from starlette.status import HTTP_403_FORBIDDEN
+from starlette.status import HTTP_401_UNAUTHORIZED
from typing_extensions import Annotated
@@ -13,6 +13,11 @@ class OpenIdConnect(SecurityBase):
"""
OpenID Connect authentication class. An instance of it would be used as a
dependency.
+
+ **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI,
+ but it doesn't implement the full OpenIdConnect scheme, for example, it doesn't use
+ the OpenIDConnect URL. You would need to to subclass it and implement it in your
+ code.
"""
def __init__(
@@ -73,13 +78,18 @@ class OpenIdConnect(SecurityBase):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
+ def make_not_authenticated_error(self) -> HTTPException:
+ return HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
async def __call__(self, request: Request) -> Optional[str]:
authorization = request.headers.get("Authorization")
if not authorization:
if self.auto_error:
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
- )
+ raise self.make_not_authenticated_error()
else:
return None
return authorization
diff --git a/pyproject.toml b/pyproject.toml
index 7d2be0074..cafcf65c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP",
]
dependencies = [
- "starlette>=0.40.0,<0.50.0",
+ "starlette>=0.40.0,<0.51.0",
"pydantic>=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0",
"typing-extensions>=4.8.0",
"annotated-doc>=0.0.2",
diff --git a/requirements-docs-insiders.txt b/requirements-docs-insiders.txt
deleted file mode 100644
index d8d3c37a9..000000000
--- a/requirements-docs-insiders.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-git+https://${TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git@9.5.30-insiders-4.53.11
-git+https://${TOKEN}@github.com/pawamoy-insiders/griffe-typing-deprecated.git
-git+https://${TOKEN}@github.com/pawamoy-insiders/mkdocstrings-python.git
diff --git a/requirements-docs.txt b/requirements-docs.txt
index 696eb2a33..05b47fe92 100644
--- a/requirements-docs.txt
+++ b/requirements-docs.txt
@@ -1,6 +1,6 @@
-e .
-r requirements-docs-tests.txt
-mkdocs-material==9.6.16
+mkdocs-material==9.7.0
mdx-include >=1.4.1,<2.0.0
mkdocs-redirects>=1.2.1,<1.3.0
typer == 0.16.0
@@ -13,7 +13,9 @@ pillow==11.3.0
cairosvg==2.8.2
mkdocstrings[python]==0.30.1
griffe-typingdoc==0.3.0
+griffe-warnings-deprecated==1.1.0
# For griffe, it formats with black
black==25.1.0
mkdocs-macros-plugin==1.4.1
markdown-include-variants==0.0.5
+python-slugify==8.0.4
diff --git a/requirements.txt b/requirements.txt
index 9180bf1be..5d9f97b75 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
-e .[all]
-r requirements-tests.txt
-r requirements-docs.txt
-pre-commit >=2.17.0,<5.0.0
+pre-commit >=4.5.0,<5.0.0
# For generating screenshots
playwright
diff --git a/scripts/docs.py b/scripts/docs.py
index 1a336a036..73f60e68c 100644
--- a/scripts/docs.py
+++ b/scripts/docs.py
@@ -4,9 +4,8 @@ import os
import re
import shutil
import subprocess
-from functools import lru_cache
+from html.parser import HTMLParser
from http.server import HTTPServer, SimpleHTTPRequestHandler
-from importlib import metadata
from multiprocessing import Pool
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
@@ -16,6 +15,7 @@ import typer
import yaml
from jinja2 import Template
from ruff.__main__ import find_ruff_bin
+from slugify import slugify as py_slugify
logging.basicConfig(level=logging.INFO)
@@ -27,8 +27,8 @@ missing_translation_snippet = """
{!../../docs/missing-translation.md!}
"""
-non_translated_sections = [
- "reference/",
+non_translated_sections = (
+ f"reference{os.sep}",
"release-notes.md",
"fastapi-people.md",
"external-links.md",
@@ -36,7 +36,7 @@ non_translated_sections = [
"management-tasks.md",
"management.md",
"contributing.md",
-]
+)
docs_path = Path("docs")
en_docs_path = Path("docs/en")
@@ -44,13 +44,39 @@ en_config_path: Path = en_docs_path / mkdocs_name
site_path = Path("site").absolute()
build_site_path = Path("site_build").absolute()
+header_pattern = re.compile(r"^(#{1,6}) (.+?)(?:\s*\{\s*(#.*)\s*\})?\s*$")
header_with_permalink_pattern = re.compile(r"^(#{1,6}) (.+?)(\s*\{\s*#.*\s*\})\s*$")
+code_block3_pattern = re.compile(r"^\s*```")
+code_block4_pattern = re.compile(r"^\s*````")
-@lru_cache
-def is_mkdocs_insiders() -> bool:
- version = metadata.version("mkdocs-material")
- return "insiders" in version
+class VisibleTextExtractor(HTMLParser):
+ """Extract visible text from a string with HTML tags."""
+
+ def __init__(self):
+ super().__init__()
+ self.text_parts = []
+
+ def handle_data(self, data):
+ self.text_parts.append(data)
+
+ def extract_visible_text(self, html: str) -> str:
+ self.reset()
+ self.text_parts = []
+ self.feed(html)
+ return "".join(self.text_parts).strip()
+
+
+def slugify(text: str) -> str:
+ return py_slugify(
+ text,
+ replacements=[
+ ("`", ""), # `dict`s -> dicts
+ ("'s", "s"), # it's -> its
+ ("'t", "t"), # don't -> dont
+ ("**", ""), # **FastAPI**s -> FastAPIs
+ ],
+ )
def get_en_config() -> Dict[str, Any]:
@@ -77,9 +103,7 @@ def complete_existing_lang(incomplete: str):
@app.callback()
def callback() -> None:
- if is_mkdocs_insiders():
- os.environ["INSIDERS_FILE"] = "../en/mkdocs.insiders.yml"
- # For MacOS with insiders and Cairo
+ # For MacOS with Cairo
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = "/opt/homebrew/lib"
@@ -115,10 +139,6 @@ def build_lang(
"""
Build the docs for a language.
"""
- insiders_env_file = os.environ.get("INSIDERS_FILE")
- print(f"Insiders file {insiders_env_file}")
- if is_mkdocs_insiders():
- print("Using insiders")
lang_path: Path = Path("docs") / lang
if not lang_path.is_dir():
typer.echo(f"The language translation doesn't seem to exist yet: {lang}")
@@ -440,5 +460,83 @@ def generate_docs_src_versions_for_file(file_path: Path) -> None:
version_file.write_text(content_format, encoding="utf-8")
+@app.command()
+def add_permalinks_page(path: Path, update_existing: bool = False):
+ """
+ Add or update header permalinks in specific page of En docs.
+ """
+
+ if not path.is_relative_to(en_docs_path / "docs"):
+ raise RuntimeError(f"Path must be inside {en_docs_path}")
+ rel_path = path.relative_to(en_docs_path / "docs")
+
+ # Skip excluded sections
+ if str(rel_path).startswith(non_translated_sections):
+ return
+
+ visible_text_extractor = VisibleTextExtractor()
+ updated_lines = []
+ in_code_block3 = False
+ in_code_block4 = False
+ permalinks = set()
+
+ with path.open("r", encoding="utf-8") as f:
+ lines = f.readlines()
+
+ for line in lines:
+ # Handle codeblocks start and end
+ if not (in_code_block3 or in_code_block4):
+ if code_block4_pattern.match(line):
+ in_code_block4 = True
+ elif code_block3_pattern.match(line):
+ in_code_block3 = True
+ else:
+ if in_code_block4 and code_block4_pattern.match(line):
+ in_code_block4 = False
+ elif in_code_block3 and code_block3_pattern.match(line):
+ in_code_block3 = False
+
+ # Process Headers only outside codeblocks
+ if not (in_code_block3 or in_code_block4):
+ match = header_pattern.match(line)
+ if match:
+ hashes, title, _permalink = match.groups()
+ if (not _permalink) or update_existing:
+ slug = slugify(visible_text_extractor.extract_visible_text(title))
+ if slug in permalinks:
+ # If the slug is already used, append a number to make it unique
+ count = 1
+ original_slug = slug
+ while slug in permalinks:
+ slug = f"{original_slug}_{count}"
+ count += 1
+ permalinks.add(slug)
+
+ line = f"{hashes} {title} {{ #{slug} }}\n"
+
+ updated_lines.append(line)
+
+ with path.open("w", encoding="utf-8") as f:
+ f.writelines(updated_lines)
+
+
+@app.command()
+def add_permalinks_pages(pages: List[Path], update_existing: bool = False) -> None:
+ """
+ Add or update header permalinks in specific pages of En docs.
+ """
+ for md_file in pages:
+ add_permalinks_page(md_file, update_existing=update_existing)
+
+
+@app.command()
+def add_permalinks(update_existing: bool = False) -> None:
+ """
+ Add or update header permalinks in all pages of En docs.
+ """
+ for md_file in en_docs_path.rglob("*.md"):
+ add_permalinks_page(md_file, update_existing=update_existing)
+
+
if __name__ == "__main__":
app()
diff --git a/tests/test_depends_hashable.py b/tests/test_depends_hashable.py
new file mode 100644
index 000000000..d57f2726e
--- /dev/null
+++ b/tests/test_depends_hashable.py
@@ -0,0 +1,25 @@
+# This is more or less a workaround to make Depends and Security hashable
+# as other tools that use them depend on that
+# Ref: https://github.com/fastapi/fastapi/pull/14320
+
+from fastapi import Depends, Security
+
+
+def dep():
+ pass
+
+
+def test_depends_hashable():
+ dep() # just for coverage
+ d1 = Depends(dep)
+ d2 = Depends(dep)
+ d3 = Depends(dep, scope="function")
+ d4 = Depends(dep, scope="function")
+
+ s1 = Security(dep)
+ s2 = Security(dep)
+
+ assert hash(d1) == hash(d2)
+ assert hash(s1) == hash(s2)
+ assert hash(d1) != hash(d3)
+ assert hash(d3) == hash(d4)
diff --git a/tests/test_security_api_key_cookie.py b/tests/test_security_api_key_cookie.py
index 4ddb8e2ee..9bacfc56e 100644
--- a/tests/test_security_api_key_cookie.py
+++ b/tests/test_security_api_key_cookie.py
@@ -32,8 +32,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
client = TestClient(app)
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_api_key_cookie_description.py b/tests/test_security_api_key_cookie_description.py
index d99d616e0..d0cab324e 100644
--- a/tests/test_security_api_key_cookie_description.py
+++ b/tests/test_security_api_key_cookie_description.py
@@ -32,8 +32,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
client = TestClient(app)
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_api_key_header.py b/tests/test_security_api_key_header.py
index 1ff883703..3e761b150 100644
--- a/tests/test_security_api_key_header.py
+++ b/tests/test_security_api_key_header.py
@@ -33,8 +33,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_api_key_header_description.py b/tests/test_security_api_key_header_description.py
index 27f9d0f29..38a1a8881 100644
--- a/tests/test_security_api_key_header_description.py
+++ b/tests/test_security_api_key_header_description.py
@@ -33,8 +33,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_api_key_query.py b/tests/test_security_api_key_query.py
index dc7a0a621..11ed19468 100644
--- a/tests/test_security_api_key_query.py
+++ b/tests/test_security_api_key_query.py
@@ -33,8 +33,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_api_key_query_description.py b/tests/test_security_api_key_query_description.py
index 35dc7743a..658798326 100644
--- a/tests/test_security_api_key_query_description.py
+++ b/tests/test_security_api_key_query_description.py
@@ -33,8 +33,9 @@ def test_security_api_key():
def test_security_api_key_no_key():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "APIKey"
def test_openapi_schema():
diff --git a/tests/test_security_http_base.py b/tests/test_security_http_base.py
index 51928bafd..8cf259a75 100644
--- a/tests/test_security_http_base.py
+++ b/tests/test_security_http_base.py
@@ -23,8 +23,9 @@ def test_security_http_base():
def test_security_http_base_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Other"
def test_openapi_schema():
diff --git a/tests/test_security_http_base_description.py b/tests/test_security_http_base_description.py
index bc79f3242..791ea59f4 100644
--- a/tests/test_security_http_base_description.py
+++ b/tests/test_security_http_base_description.py
@@ -23,8 +23,9 @@ def test_security_http_base():
def test_security_http_base_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Other"
def test_openapi_schema():
diff --git a/tests/test_security_http_basic_optional.py b/tests/test_security_http_basic_optional.py
index 9b6cb6c45..7071f381a 100644
--- a/tests/test_security_http_basic_optional.py
+++ b/tests/test_security_http_basic_optional.py
@@ -38,7 +38,7 @@ def test_security_http_basic_invalid_credentials():
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_security_http_basic_non_basic_credentials():
@@ -47,7 +47,7 @@ def test_security_http_basic_non_basic_credentials():
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_openapi_schema():
diff --git a/tests/test_security_http_basic_realm.py b/tests/test_security_http_basic_realm.py
index 9fc33971a..ec7371f90 100644
--- a/tests/test_security_http_basic_realm.py
+++ b/tests/test_security_http_basic_realm.py
@@ -36,7 +36,7 @@ def test_security_http_basic_invalid_credentials():
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"'
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_security_http_basic_non_basic_credentials():
@@ -45,7 +45,7 @@ def test_security_http_basic_non_basic_credentials():
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"'
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_openapi_schema():
diff --git a/tests/test_security_http_basic_realm_description.py b/tests/test_security_http_basic_realm_description.py
index 02122442e..a93d5fc86 100644
--- a/tests/test_security_http_basic_realm_description.py
+++ b/tests/test_security_http_basic_realm_description.py
@@ -36,7 +36,7 @@ def test_security_http_basic_invalid_credentials():
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"'
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_security_http_basic_non_basic_credentials():
@@ -45,7 +45,7 @@ def test_security_http_basic_non_basic_credentials():
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == 'Basic realm="simple"'
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_openapi_schema():
diff --git a/tests/test_security_http_bearer.py b/tests/test_security_http_bearer.py
index 5b9e2d691..961b42f4d 100644
--- a/tests/test_security_http_bearer.py
+++ b/tests/test_security_http_bearer.py
@@ -23,14 +23,16 @@ def test_security_http_bearer():
def test_security_http_bearer_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_security_http_bearer_incorrect_scheme_credentials():
response = client.get("/users/me", headers={"Authorization": "Basic notreally"})
- assert response.status_code == 403, response.text
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.status_code == 401, response.text
+ assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_openapi_schema():
diff --git a/tests/test_security_http_bearer_description.py b/tests/test_security_http_bearer_description.py
index 2f11c3a14..e16994abc 100644
--- a/tests/test_security_http_bearer_description.py
+++ b/tests/test_security_http_bearer_description.py
@@ -23,14 +23,16 @@ def test_security_http_bearer():
def test_security_http_bearer_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_security_http_bearer_incorrect_scheme_credentials():
response = client.get("/users/me", headers={"Authorization": "Basic notreally"})
- assert response.status_code == 403, response.text
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.status_code == 401, response.text
+ assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_openapi_schema():
diff --git a/tests/test_security_http_digest.py b/tests/test_security_http_digest.py
index 133d35763..3fad4c7a5 100644
--- a/tests/test_security_http_digest.py
+++ b/tests/test_security_http_digest.py
@@ -23,16 +23,18 @@ def test_security_http_digest():
def test_security_http_digest_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Digest"
def test_security_http_digest_incorrect_scheme_credentials():
response = client.get(
"/users/me", headers={"Authorization": "Other invalidauthorization"}
)
- assert response.status_code == 403, response.text
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.status_code == 401, response.text
+ assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Digest"
def test_openapi_schema():
diff --git a/tests/test_security_http_digest_description.py b/tests/test_security_http_digest_description.py
index 4e31a0c00..319416a07 100644
--- a/tests/test_security_http_digest_description.py
+++ b/tests/test_security_http_digest_description.py
@@ -23,16 +23,18 @@ def test_security_http_digest():
def test_security_http_digest_no_credentials():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Digest"
def test_security_http_digest_incorrect_scheme_credentials():
response = client.get(
"/users/me", headers={"Authorization": "Other invalidauthorization"}
)
- assert response.status_code == 403, response.text
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.status_code == 401, response.text
+ assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Digest"
def test_openapi_schema():
diff --git a/tests/test_security_oauth2.py b/tests/test_security_oauth2.py
index 2b7e3457a..804e4152d 100644
--- a/tests/test_security_oauth2.py
+++ b/tests/test_security_oauth2.py
@@ -56,8 +56,9 @@ def test_security_oauth2_password_other_header():
def test_security_oauth2_password_bearer_no_header():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_strict_login_no_data():
diff --git a/tests/test_security_openid_connect.py b/tests/test_security_openid_connect.py
index 1e322e640..c9a0a8db7 100644
--- a/tests/test_security_openid_connect.py
+++ b/tests/test_security_openid_connect.py
@@ -39,8 +39,9 @@ def test_security_oauth2_password_other_header():
def test_security_oauth2_password_bearer_no_header():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_openapi_schema():
diff --git a/tests/test_security_openid_connect_description.py b/tests/test_security_openid_connect_description.py
index 44cf57f86..d008cbc63 100644
--- a/tests/test_security_openid_connect_description.py
+++ b/tests/test_security_openid_connect_description.py
@@ -41,8 +41,9 @@ def test_security_oauth2_password_other_header():
def test_security_oauth2_password_bearer_no_header():
response = client.get("/users/me")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
+ assert response.headers["WWW-Authenticate"] == "Bearer"
def test_openapi_schema():
diff --git a/tests/test_top_level_security_scheme_in_openapi.py b/tests/test_top_level_security_scheme_in_openapi.py
index e2de31af5..a36c66d1a 100644
--- a/tests/test_top_level_security_scheme_in_openapi.py
+++ b/tests/test_top_level_security_scheme_in_openapi.py
@@ -27,7 +27,7 @@ def test_get_root():
def test_get_root_no_token():
response = client.get("/")
- assert response.status_code == 403, response.text
+ assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}
diff --git a/docs/en/mkdocs.no-insiders.yml b/tests/test_tutorial/test_authentication_error_status_code/__init__.py
similarity index 100%
rename from docs/en/mkdocs.no-insiders.yml
rename to tests/test_tutorial/test_authentication_error_status_code/__init__.py
diff --git a/tests/test_tutorial/test_authentication_error_status_code/test_tutorial001.py b/tests/test_tutorial/test_authentication_error_status_code/test_tutorial001.py
new file mode 100644
index 000000000..bbd7bff30
--- /dev/null
+++ b/tests/test_tutorial/test_authentication_error_status_code/test_tutorial001.py
@@ -0,0 +1,69 @@
+import importlib
+
+import pytest
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+
+from ...utils import needs_py39
+
+
+@pytest.fixture(
+ name="client",
+ params=[
+ "tutorial001_an",
+ pytest.param("tutorial001_an_py39", marks=needs_py39),
+ ],
+)
+def get_client(request: pytest.FixtureRequest):
+ mod = importlib.import_module(
+ f"docs_src.authentication_error_status_code.{request.param}"
+ )
+
+ client = TestClient(mod.app)
+ return client
+
+
+def test_get_me(client: TestClient):
+ response = client.get("/me", headers={"Authorization": "Bearer secrettoken"})
+ assert response.status_code == 200
+ assert response.json() == {
+ "message": "You are authenticated",
+ "token": "secrettoken",
+ }
+
+
+def test_get_me_no_credentials(client: TestClient):
+ response = client.get("/me")
+ assert response.status_code == 403
+ assert response.json() == {"detail": "Not authenticated"}
+
+
+def test_openapi_schema(client: TestClient):
+ response = client.get("/openapi.json")
+ assert response.status_code == 200, response.text
+ assert response.json() == snapshot(
+ {
+ "openapi": "3.1.0",
+ "info": {"title": "FastAPI", "version": "0.1.0"},
+ "paths": {
+ "/me": {
+ "get": {
+ "summary": "Read Me",
+ "operationId": "read_me_me_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ }
+ },
+ "security": [{"HTTPBearer403": []}],
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "HTTPBearer403": {"type": "http", "scheme": "bearer"}
+ }
+ },
+ }
+ )
diff --git a/tests/test_tutorial/test_security/test_tutorial003.py b/tests/test_tutorial/test_security/test_tutorial003.py
index 2bbb2e851..6b8735113 100644
--- a/tests/test_tutorial/test_security/test_tutorial003.py
+++ b/tests/test_tutorial/test_security/test_tutorial003.py
@@ -66,7 +66,7 @@ def test_token(client: TestClient):
def test_incorrect_token(client: TestClient):
response = client.get("/users/me", headers={"Authorization": "Bearer nonexistent"})
assert response.status_code == 401, response.text
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
assert response.headers["WWW-Authenticate"] == "Bearer"
diff --git a/tests/test_tutorial/test_security/test_tutorial006.py b/tests/test_tutorial/test_security/test_tutorial006.py
index 40b413806..9587159dc 100644
--- a/tests/test_tutorial/test_security/test_tutorial006.py
+++ b/tests/test_tutorial/test_security/test_tutorial006.py
@@ -41,7 +41,7 @@ def test_security_http_basic_invalid_credentials(client: TestClient):
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_security_http_basic_non_basic_credentials(client: TestClient):
@@ -50,7 +50,7 @@ def test_security_http_basic_non_basic_credentials(client: TestClient):
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
- assert response.json() == {"detail": "Invalid authentication credentials"}
+ assert response.json() == {"detail": "Not authenticated"}
def test_openapi_schema(client: TestClient):