Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
e1e755fde5 | |||
b14e6a0be5 | |||
1dec3988d5 | |||
1b6d754855 | |||
0e6ff98f03 | |||
8b3a339196 | |||
b01f233208 | |||
4355e6dc42 | |||
c3ebad42d5 | |||
c5b1bdeda9 | |||
821df02758 | |||
9f7b090273 | |||
22d0a9852c | |||
6f060dc2bf | |||
f4b38e1c69 | |||
b465394766 | |||
9c46237905 | |||
3da485c945 | |||
9c1f843283 | |||
ef7c265d8e | |||
395ec1c7f7 | |||
9249885c80 | |||
5f429797ff | |||
850db9f590 | |||
f6a84fd3aa | |||
4c2b197850 |
15 changed files with 198 additions and 155 deletions
|
@ -1,93 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
verbose:
|
||||
description: "Verbose"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: container
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "0.5.16"
|
||||
|
||||
- name: Install
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests (API call)
|
||||
run: .venv/bin/pytest -s tests/basic.py
|
||||
|
||||
- name: Get version with git describe
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(git describe)" >> $GITHUB_OUTPUT
|
||||
echo "$VERSION"
|
||||
|
||||
- name: Check if the container should be built
|
||||
id: builder
|
||||
env:
|
||||
RUN: ${{ toJSON(inputs.build || !contains(steps.version.outputs.version, '-')) }}
|
||||
run: |
|
||||
echo "run=$RUN" >> $GITHUB_OUTPUT
|
||||
echo "Run build: $RUN"
|
||||
|
||||
- name: Set the version in pyproject.toml (workaround for uv not supporting dynamic version)
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: sed "s/0.0.0/$VERSION/" -i pyproject.toml
|
||||
|
||||
- name: Workaround for bug of podman-login
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
run: |
|
||||
mkdir -p $HOME/.docker
|
||||
echo "{ \"auths\": {} }" > $HOME/.docker/config.json
|
||||
|
||||
- name: Log in to the container registry (with another workaround)
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
uses: actions/podman-login@v1
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
auth_file_path: /tmp/auth.json
|
||||
|
||||
- name: Build the container image
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
uses: actions/buildah-build@v1
|
||||
with:
|
||||
image: oidc-fastapi-test
|
||||
oci: true
|
||||
labels: oidc-fastapi-test
|
||||
tags: latest ${{ steps.version.outputs.version }}
|
||||
containerfiles: |
|
||||
./Containerfile
|
||||
|
||||
- name: Push the image to the registry
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
uses: actions/push-to-registry@v2
|
||||
with:
|
||||
registry: "docker://${{ vars.REGISTRY }}/${{ vars.ORGANISATION }}"
|
||||
image: oidc-fastapi-test
|
||||
tags: latest ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build wheel
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
run: uv build --wheel
|
||||
|
||||
- name: Publish Python package (home)
|
||||
if: fromJSON(steps.builder.outputs.run)
|
||||
env:
|
||||
LOCAL_PYPI_TOKEN: ${{ secrets.LOCAL_PYPI_TOKEN }}
|
||||
run: uv publish --publish-url https://code.philo.ydns.eu/api/packages/philorg/pypi --token $LOCAL_PYPI_TOKEN
|
||||
continue-on-error: true
|
|
@ -1,28 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
verbose:
|
||||
description: "Verbose"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: container
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "0.5.16"
|
||||
|
||||
- name: Install
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests (API call)
|
||||
run: .venv/bin/pytest -s tests/basic.py
|
63
.woodpecker/build.yaml
Normal file
63
.woodpecker/build.yaml
Normal file
|
@ -0,0 +1,63 @@
|
|||
when:
|
||||
- event: manual
|
||||
- event: tag
|
||||
|
||||
depends_on:
|
||||
- test
|
||||
|
||||
steps:
|
||||
python_sync:
|
||||
image: code.philo.ydns.eu/philorg/uv
|
||||
volumes:
|
||||
- uv-cache:/uv-cache
|
||||
environment:
|
||||
UV_CACHE_DIR: /uv-cache
|
||||
UV_LINK_MODE: copy
|
||||
commands:
|
||||
- uv sync
|
||||
|
||||
python_build:
|
||||
image: code.philo.ydns.eu/philorg/uv
|
||||
volumes:
|
||||
- uv-cache:/uv-cache
|
||||
environment:
|
||||
UV_CACHE_DIR: /uv-cache
|
||||
UV_LINK_MODE: copy
|
||||
commands:
|
||||
- uv build --wheel
|
||||
- uv cache prune --ci
|
||||
|
||||
python_publish:
|
||||
image: code.philo.ydns.eu/philorg/uv
|
||||
environment:
|
||||
OWNER: philorg
|
||||
REGISTRY_URL: https://code.philo.ydns.eu
|
||||
REGISTRY_TOKEN:
|
||||
from_secret: registry_token
|
||||
commands:
|
||||
- uv publish --publish-url $REGISTRY_URL/api/packages/$OWNER/pypi --token $REGISTRY_TOKEN dist/*.whl
|
||||
failure: ignore
|
||||
|
||||
container_build_publish:
|
||||
image: quay.io/podman/stable:latest
|
||||
# Caution: This image is built daily. It might fill up your image store quickly.
|
||||
#pull: true
|
||||
volumes:
|
||||
- containers:/var/lib/containers
|
||||
- uv:/root/.cache/uv
|
||||
# Fill in the trusted checkbox in Woodpecker's settings as well
|
||||
privileged: true
|
||||
environment:
|
||||
registry: code.philo.ydns.eu
|
||||
org: philorg
|
||||
container_name: oidc-fastapi-test
|
||||
registry_token:
|
||||
from_secret: registry_token
|
||||
commands:
|
||||
# Login at the registry
|
||||
- podman login -u __token__ --password $registry_token $registry
|
||||
# Build the container image
|
||||
- podman build --volume=/var/lib/containers:/var/lib/containers --tag $registry/$org/$container_name:latest --tag $registry/$org/$container_name:$CI_COMMIT_TAG .
|
||||
# Push the image
|
||||
- podman push $registry/$org/$container_name:latest
|
||||
- podman push $registry/$org/$container_name:$CI_COMMIT_TAG
|
21
.woodpecker/test.yaml
Normal file
21
.woodpecker/test.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
- event: manual
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
sync:
|
||||
image: code.philo.ydns.eu/philorg/uv
|
||||
volumes:
|
||||
- uv-cache:/uv-cache
|
||||
environment:
|
||||
UV_CACHE_DIR: /uv-cache
|
||||
UV_LINK_MODE: copy
|
||||
commands:
|
||||
- uv sync
|
||||
|
||||
test:
|
||||
image: code.philo.ydns.eu/philorg/uv
|
||||
commands:
|
||||
- .venv/bin/pytest -s tests/basic.py
|
|
@ -1,6 +1,7 @@
|
|||
FROM docker.io/library/python:alpine
|
||||
FROM docker.io/library/python:3.13-slim
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
COPY --from=docker.io/python:3.13 /usr/bin/git /usr/local/bin/git
|
||||
|
||||
COPY . /app
|
||||
|
||||
|
|
62
README.md
62
README.md
|
@ -52,31 +52,59 @@ given by the OIDC providers.
|
|||
For example:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
secret_key: "ASecretNoOneKnows"
|
||||
show_session_details: yes
|
||||
secret_key: AVeryWellKeptSecret
|
||||
debug_token: no
|
||||
show_token: yes
|
||||
log: yes
|
||||
|
||||
auth:
|
||||
providers:
|
||||
- id: auth0
|
||||
name: Okta / Auth0
|
||||
url: "https://<your_auth0_app_URL>"
|
||||
client_id: "<your_auth0_client_id>"
|
||||
client_secret: "client_secret_generated_by_auth0"
|
||||
hint: "A hint for test credentials"
|
||||
url: https://<your_auth0_app_URL>
|
||||
public_key_url: https://<your_auth0_app_URL>/pem
|
||||
client_id: <your_auth0_client_id>
|
||||
client_secret: client_secret_generated_by_auth0
|
||||
hint: A hint for test credentials
|
||||
|
||||
- id: keycloak
|
||||
name: Keycloak at somewhere
|
||||
url: "https://<the_keycloak_realm_url>"
|
||||
account_url_template: "/account"
|
||||
client_id: "<your_keycloak_client_id>"
|
||||
client_secret: "client_secret_generated_by_keycloak"
|
||||
hint: "User: foo, password: foofoo"
|
||||
url: https://<the_keycloak_realm_url>
|
||||
info_url: https://philo.ydns.eu/auth/realms/test
|
||||
account_url_template: /account
|
||||
client_id: <your_keycloak_client_id>
|
||||
client_secret: <client_secret_generated_by_keycloak>
|
||||
hint: A hint for test credentials
|
||||
code_challenge_method: S256
|
||||
resource_provider_scopes:
|
||||
- get:time
|
||||
- get:bs
|
||||
resource_providers:
|
||||
- id: <third_party_resource_provider_id>
|
||||
name: A third party resource provider
|
||||
base_url: https://some.example.com/
|
||||
verify_ssl: yes
|
||||
resources:
|
||||
- name: Public RS2
|
||||
resource_name: public
|
||||
url: resource/public
|
||||
- name: BS RS2
|
||||
resource_name: bs
|
||||
url: resource/bs
|
||||
- name: Time RS2
|
||||
resource_name: time
|
||||
url: resource/time
|
||||
|
||||
- id: codeberg
|
||||
disabled: no
|
||||
name: Codeberg
|
||||
url: "https://codeberg.org"
|
||||
account_url_template: "/user/settings"
|
||||
client_id: "<your_codeberg_client_id>"
|
||||
client_secret: "client_secret_generated_by_codeberg"
|
||||
url: https://codeberg.org
|
||||
account_url_template: /user/settings
|
||||
client_id: <your_codeberg_client_id>
|
||||
client_secret: client_secret_generated_by_codeberg
|
||||
info_url: https://codeberg.org/login/oauth/keys
|
||||
session_key: sub
|
||||
skip_verify_signature: no
|
||||
resources:
|
||||
- name: List of repos
|
||||
id: repos
|
||||
|
@ -99,3 +127,5 @@ with the setting file in the current working directory:
|
|||
```sh
|
||||
podman run -p 8000:80 --env OIDC_TEST_CONFIG_FILE=/app/settings.yaml --mount type=bind,source=settings.yaml,destination=/app/settings.yaml code.philo.ydns.eu/philorg/oidc-fastapi-test:latest
|
||||
```
|
||||
|
||||
[](https://code.philo.ydns.eu/woodpecker/repos/22)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "oidc-fastapi-test"
|
||||
version = "0.0.0"
|
||||
# dynamic = ["version"]
|
||||
#version = "0.0.0"
|
||||
dynamic = ["version"]
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
@ -24,14 +24,21 @@ dependencies = [
|
|||
oidc-test = "oidc_test.main:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ipdb>=0.13.13", "pytest>=8.3.4"]
|
||||
dev = ["dunamai>=1.23.0", "ipdb>=0.13.13", "pytest>=8.3.4"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "uv-dynamic-versioning"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/oidc_test"]
|
||||
package = true
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
style = "semver"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import importlib.metadata
|
||||
|
||||
try:
|
||||
from dunamai import Version, Style
|
||||
|
||||
__version__ = Version.from_git().serialize(style=Style.SemVer, dirty=True)
|
||||
except (ImportError, RuntimeError):
|
||||
# __name__ could be used if the package name is the same
|
||||
# as the directory - not the case here
|
||||
# __version__ = importlib.metadata.version(__name__)
|
||||
__version__ = importlib.metadata.version("oidc-fastapi-test")
|
|
@ -61,28 +61,34 @@ class Provider(AuthProviderSettings):
|
|||
if self.info_url is not None:
|
||||
try:
|
||||
provider_info = await client.get(self.info_url)
|
||||
except Exception:
|
||||
except Exception as err:
|
||||
logger.debug("Provider_info: cannot connect")
|
||||
logger.exception(err)
|
||||
raise NoPublicKey
|
||||
try:
|
||||
self.info = provider_info.json()
|
||||
except JSONDecodeError:
|
||||
logger.debug("Provider_info: cannot decode json response")
|
||||
raise NoPublicKey
|
||||
if "public_key" in self.info:
|
||||
# For Keycloak
|
||||
try:
|
||||
public_key = str(self.info["public_key"])
|
||||
except KeyError:
|
||||
logger.debug("Provider_info: cannot get public_key")
|
||||
raise NoPublicKey
|
||||
elif "keys" in self.info:
|
||||
# For Forgejo/Gitea
|
||||
try:
|
||||
public_key = str(self.info["keys"][0]["n"])
|
||||
except KeyError:
|
||||
logger.debug("Provider_info: cannot get key 0.n")
|
||||
raise NoPublicKey
|
||||
if self.public_key_url is not None:
|
||||
resp = await client.get(self.public_key_url)
|
||||
public_key = resp.text
|
||||
if public_key is None:
|
||||
logger.debug("Provider_info: cannot determine public key")
|
||||
raise NoPublicKey
|
||||
self.public_key = "\n".join(
|
||||
["-----BEGIN PUBLIC KEY-----", public_key, "-----END PUBLIC KEY-----"]
|
||||
|
|
|
@ -5,10 +5,8 @@ import logging
|
|||
from fastapi import HTTPException, Request, Depends, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError, StarletteOAuth2App
|
||||
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
|
||||
|
||||
# from authlib.oauth1.auth import OAuthToken
|
||||
from authlib.oauth2.rfc6749 import OAuth2Token
|
||||
from jwt import ExpiredSignatureError, InvalidKeyError, DecodeError, PyJWTError
|
||||
|
||||
from oidc_test.auth.provider import Provider
|
||||
from oidc_test.models import User
|
||||
|
@ -22,7 +20,7 @@ logger = logging.getLogger("oidc-test")
|
|||
async def fetch_token(name, request):
|
||||
assert name is not None
|
||||
assert request is not None
|
||||
logger.warn("TODO: fetch_token")
|
||||
logger.warning("TODO: fetch_token")
|
||||
...
|
||||
# if name in oidc_providers:
|
||||
# model = OAuth2Token
|
||||
|
@ -34,7 +32,10 @@ async def fetch_token(name, request):
|
|||
|
||||
|
||||
async def update_token(
|
||||
provider_id, token, refresh_token: str | None = None, access_token: str | None = None
|
||||
provider_id,
|
||||
token,
|
||||
refresh_token: str | None = None,
|
||||
access_token: str | None = None,
|
||||
):
|
||||
"""Update the token in the database"""
|
||||
provider = providers[provider_id]
|
||||
|
|
|
@ -29,6 +29,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token
|
|||
# from fastapi.security import OpenIdConnect
|
||||
# from pkce import generate_code_verifier, generate_pkce_pair
|
||||
|
||||
from oidc_test import __version__
|
||||
from oidc_test.registry import registry
|
||||
from oidc_test.auth.provider import NoPublicKey, Provider
|
||||
from oidc_test.auth.utils import (
|
||||
|
@ -108,6 +109,7 @@ async def home(
|
|||
"show_token": settings.show_token,
|
||||
"user": user,
|
||||
"now": datetime.now(),
|
||||
"__version__": __version__,
|
||||
}
|
||||
if provider is None or token is None:
|
||||
context["providers"] = providers
|
||||
|
@ -123,19 +125,20 @@ async def home(
|
|||
try:
|
||||
access_token_parsed = provider.decode(token["access_token"], verify_signature=False)
|
||||
context["access_token_parsed"] = access_token_parsed
|
||||
context["access_token_scope"] = access_token_parsed.get("scope")
|
||||
except PyJWTError as err:
|
||||
access_token_parsed = {"Cannot parse": err.__class__.__name__}
|
||||
context["access_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||
context["access_token_scope"] = None
|
||||
try:
|
||||
id_token_parsed = provider.decode(token["id_token"], verify_signature=False)
|
||||
context["id_token_parsed"] = id_token_parsed
|
||||
except PyJWTError as err:
|
||||
id_token_parsed = {"Cannot parse": err.__class__.__name__}
|
||||
context["id_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||
try:
|
||||
refresh_token_parsed = provider.decode(token["refresh_token"], verify_signature=False)
|
||||
context["refresh_token_parsed"] = refresh_token_parsed
|
||||
except PyJWTError as err:
|
||||
refresh_token_parsed = {"Cannot parse": err.__class__.__name__}
|
||||
context["access_token_scope"] = access_token_parsed.get("scope")
|
||||
context["refresh_token_parsed"] = {"Cannot parse": err.__class__.__name__}
|
||||
context["resources"] = registry.resources
|
||||
context["resource_providers"] = provider.resource_providers
|
||||
return templates.TemplateResponse(name="home.html", request=request, context=context)
|
||||
|
|
|
@ -21,6 +21,12 @@ hr {
|
|||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.version {
|
||||
position: absolute;
|
||||
font-size: 75%;
|
||||
top: 0.3em;
|
||||
right: 0.3em;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
<script src="{{ url_for('static', path='/utils.js') }}"></script>
|
||||
</head>
|
||||
<body onload="checkPerms('links-to-check', '{{ access_token }}', '{{ auth_provider.id }}')">
|
||||
<div class="version">v. {{ __version__}}</div>
|
||||
<h1>OIDC-test - FastAPI client</h1>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -93,13 +93,13 @@
|
|||
{% for resource in auth_provider.resources %}
|
||||
{% if resource.default_resource_id %}
|
||||
<button resource-name="{{ resource.resource_name }}"
|
||||
resource-id="{{ resource.default_resource_id }}"
|
||||
resource-id="{{ resource.default_resource_id }}"
|
||||
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}', '{{ auth_provider.id }}', '{{ resource.default_resource_id }}')"
|
||||
>
|
||||
{{ resource.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button resource-name="{{ resource.name }}"
|
||||
<button resource-name="{{ resource.resource_name }}"
|
||||
onclick="get_resource('{{ resource.resource_name }}', '{{ access_token }}', '{{ auth_provider.id }}')"
|
||||
>
|
||||
{{ resource.name }}
|
||||
|
|
16
uv.lock
generated
16
uv.lock
generated
|
@ -1,4 +1,5 @@
|
|||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
|
@ -206,6 +207,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunamai"
|
||||
version = "1.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/4e/a5c8c337a1d9ac0384298ade02d322741fb5998041a5ea74d1cd2a4a1d47/dunamai-1.23.0.tar.gz", hash = "sha256:a163746de7ea5acb6dacdab3a6ad621ebc612ed1e528aaa8beedb8887fccd2c4", size = 44681 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/963169386309fec4f96fd61210ac0a0666887d0fb0a50205395674d20b71/dunamai-1.23.0-py3-none-any.whl", hash = "sha256:a0906d876e92441793c6a423e16a4802752e723e9c9a5aabdc5535df02dbe041", size = 26342 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.0"
|
||||
|
@ -482,7 +495,6 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "oidc-fastapi-test"
|
||||
version = "0.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
|
@ -501,6 +513,7 @@ dependencies = [
|
|||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "dunamai" },
|
||||
{ name = "ipdb" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
@ -523,6 +536,7 @@ requires-dist = [
|
|||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "dunamai", specifier = ">=1.23.0" },
|
||||
{ name = "ipdb", specifier = ">=0.13.13" },
|
||||
{ name = "pytest", specifier = ">=8.3.4" },
|
||||
]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue