chore: add locust files + testing setup
This commit is contained in:
@@ -1,10 +1,20 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||||
|
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||||
|
# accounts are for and how they're used.
|
||||||
|
LOAD_TEST_USER_COUNT = 500
|
||||||
|
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||||
|
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
@admin.register(CustomUser)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||||
"""Admin interface for CustomUser."""
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
# Add custom fields to the user admin
|
||||||
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
# Make CAS fields readonly in admin
|
# Make CAS fields readonly in admin
|
||||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
changelist_actions = ("create_load_test_users",)
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
def get_cas_groups_display(self, obj):
|
||||||
"""Display CAS groups in admin list."""
|
"""Display CAS groups in admin list."""
|
||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_changelist_actions(self, request):
|
||||||
|
# Second layer of the same guard rail as the login endpoint itself
|
||||||
|
# (see testing_auth_views.py): the button doesn't even show up
|
||||||
|
# unless this deployment opted into load testing.
|
||||||
|
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
return []
|
||||||
|
return super().get_changelist_actions(request)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Create load test users",
|
||||||
|
description=(
|
||||||
|
"Create/reset the local loadtestN accounts used by "
|
||||||
|
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||||
|
"never the real CAS server"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def create_load_test_users(self, request, _queryset):
|
||||||
|
created = 0
|
||||||
|
for i in range(LOAD_TEST_USER_COUNT):
|
||||||
|
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||||
|
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||||
|
user.set_password(LOAD_TEST_PASSWORD)
|
||||||
|
user.save()
|
||||||
|
created += was_created
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||||
|
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Locust load test for the place game.
|
||||||
|
|
||||||
|
Simulates real players: log in, fetch the canvas, open the same websocket
|
||||||
|
the frontend uses, and place a pixel as soon as the per-user cooldown
|
||||||
|
allows - while a background greenlet keeps draining the broadcast stream so
|
||||||
|
each idle connection costs what a real one costs.
|
||||||
|
|
||||||
|
A placement that comes back rejected or errors out (timeout, dropped
|
||||||
|
connection, ...) has a configurable chance of triggering a "reload" -
|
||||||
|
tearing the socket down and redoing the page load (HTML + canvas fetch +
|
||||||
|
fresh websocket), the way a real, mildly frustrated player mashes refresh
|
||||||
|
instead of quietly waiting. That reconnect-storm behavior - many clients
|
||||||
|
reloading around the same time - is itself part of what's worth measuring
|
||||||
|
under load, not just steady-state placement throughput.
|
||||||
|
|
||||||
|
Authentication uses local Django accounts (ModelBackend), never the real
|
||||||
|
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
|
||||||
|
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
|
||||||
|
production.
|
||||||
|
|
||||||
|
Setup, on the target server:
|
||||||
|
export LOAD_TEST_LOGIN_ENABLED=true
|
||||||
|
Then, in the Django admin, open Accounts > Users and click "Create load
|
||||||
|
test users" (see accounts/admin.py) - it's only visible with the env
|
||||||
|
var above set. Creates/resets loadtest0..loadtest499.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
uv run locust -f loadtest/locustfile.py --host http://localhost:7777
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
PLACE_EVENT_PATH event slug under test (default: "loadtest")
|
||||||
|
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
|
||||||
|
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
|
||||||
|
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
|
||||||
|
RELOAD_ON_FAILURE_PROBABILITY chance a failed placement reloads instead
|
||||||
|
of just waiting for the next cooldown
|
||||||
|
(default: 0.7)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
import gevent
|
||||||
|
import websocket
|
||||||
|
from gevent.queue import Empty, Queue
|
||||||
|
from locust import HttpUser, between, events, task
|
||||||
|
|
||||||
|
EVENT_PATH = os.environ.get("PLACE_EVENT_PATH", "loadtest")
|
||||||
|
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
|
||||||
|
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
|
||||||
|
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
|
||||||
|
RELOAD_ON_FAILURE_PROBABILITY = float(os.environ.get("RELOAD_ON_FAILURE_PROBABILITY", "0.7"))
|
||||||
|
|
||||||
|
# Mirrors place/models.py::PALETTE - kept as a plain constant here since
|
||||||
|
# loadtest runs standalone, without Django settings loaded.
|
||||||
|
PALETTE_SIZE = 16
|
||||||
|
|
||||||
|
CANVAS_HEADER = struct.Struct("<HH")
|
||||||
|
|
||||||
|
_next_account_index = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _next_username() -> str:
|
||||||
|
"""Round-robins through the pool of pre-created accounts so N Locust
|
||||||
|
users spread evenly across USER_COUNT real accounts even when N differs
|
||||||
|
from USER_COUNT."""
|
||||||
|
global _next_account_index
|
||||||
|
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
|
||||||
|
_next_account_index += 1
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceUser(HttpUser):
|
||||||
|
wait_time = between(1, 3)
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.username = _next_username()
|
||||||
|
self._login()
|
||||||
|
self.width, self.height = self._fetch_canvas_size()
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
self._teardown_ws()
|
||||||
|
|
||||||
|
def _login(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/testing/login/",
|
||||||
|
data={"username": self.username, "password": PASSWORD},
|
||||||
|
name="/testing/login/",
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"login failed for {self.username}: {response.status_code} "
|
||||||
|
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
|
||||||
|
"target, and did you run the 'Create load test users' admin "
|
||||||
|
"action (accounts/admin.py)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_canvas_size(self) -> tuple[int, int]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{EVENT_PATH}/place/canvas",
|
||||||
|
name="/api/events/[event]/place/canvas",
|
||||||
|
)
|
||||||
|
width, height = CANVAS_HEADER.unpack_from(response.content, 0)
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""(Re)opens the websocket and a fresh reader greenlet, then syncs
|
||||||
|
next_placement_at to the server's own cooldown clock - every
|
||||||
|
connection starts with an 's|<next>|...' message (see
|
||||||
|
place/consumers.py::_initial_state) - rather than trusting local
|
||||||
|
bookkeeping. That sync is exactly what makes a page reload
|
||||||
|
meaningful to simulate: it re-anchors client state to the server's
|
||||||
|
truth instead of just reopening a blank socket."""
|
||||||
|
self.ws = self._open_ws()
|
||||||
|
self.next_placement_at = self._read_initial_next_placement_at()
|
||||||
|
self.reply_queue = Queue()
|
||||||
|
self._reader = gevent.spawn(self._reader_loop)
|
||||||
|
|
||||||
|
def _teardown_ws(self):
|
||||||
|
reader = getattr(self, "_reader", None)
|
||||||
|
if reader is not None:
|
||||||
|
reader.kill(block=False)
|
||||||
|
ws = getattr(self, "ws", None)
|
||||||
|
if ws is not None:
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _reload(self):
|
||||||
|
"""What a real player does after a placement fails: hit refresh.
|
||||||
|
Tears the socket down and redoes the page load over HTTP plus a
|
||||||
|
brand-new websocket - the same reconnect churn a real outage or
|
||||||
|
rejection wave would cause."""
|
||||||
|
self._teardown_ws()
|
||||||
|
self.client.get(f"/{EVENT_PATH}/place", name="/[event]/place (reload)")
|
||||||
|
self.width, self.height = self._fetch_canvas_size()
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _open_ws(self):
|
||||||
|
scheme = "wss" if self.client.base_url.startswith("https") else "ws"
|
||||||
|
host = self.client.base_url.split("://", 1)[1].rstrip("/")
|
||||||
|
url = f"{scheme}://{host}/ws/place/{EVENT_PATH}/"
|
||||||
|
cookie_header = "; ".join(
|
||||||
|
f"{name}={value}" for name, value in self.client.cookies.get_dict().items()
|
||||||
|
)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
exc = None
|
||||||
|
ws = None
|
||||||
|
try:
|
||||||
|
ws = websocket.create_connection(
|
||||||
|
url, header=[f"Cookie: {cookie_header}"], timeout=10
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
exc = e
|
||||||
|
events.request.fire(
|
||||||
|
request_type="WS",
|
||||||
|
name="ws_connect",
|
||||||
|
response_time=(time.perf_counter() - start) * 1000,
|
||||||
|
response_length=0,
|
||||||
|
exception=exc,
|
||||||
|
)
|
||||||
|
if exc is not None:
|
||||||
|
raise exc
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def _read_initial_next_placement_at(self) -> float:
|
||||||
|
message = self.ws.recv()
|
||||||
|
next_ms = message.split("|")[1]
|
||||||
|
return int(next_ms) / 1000 if next_ms else 0.0
|
||||||
|
|
||||||
|
def _reader_loop(self):
|
||||||
|
"""The only greenlet that ever calls ws.recv() - place_pixel below
|
||||||
|
only ever writes, and reads back via reply_queue, so there's no race
|
||||||
|
between two greenlets reading the same socket. Broadcast pixels
|
||||||
|
from other players (and 'e|...' resize messages) are read and
|
||||||
|
dropped here, same as a real idle viewer."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = self.ws.recv()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
if message[0] in ("k", "r"):
|
||||||
|
self.reply_queue.put(message)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def place_pixel(self):
|
||||||
|
now = time.time()
|
||||||
|
if now < self.next_placement_at:
|
||||||
|
return # still on cooldown - a real client wouldn't send either
|
||||||
|
|
||||||
|
x = random.randrange(self.width)
|
||||||
|
y = random.randrange(self.height)
|
||||||
|
color = random.randrange(PALETTE_SIZE)
|
||||||
|
message = f"p|{x}|{y}|{color}"
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
exc = None
|
||||||
|
reply = None
|
||||||
|
try:
|
||||||
|
self.ws.send(message)
|
||||||
|
reply = self.reply_queue.get(timeout=10)
|
||||||
|
except (Empty, Exception) as e:
|
||||||
|
exc = e
|
||||||
|
|
||||||
|
events.request.fire(
|
||||||
|
request_type="WS",
|
||||||
|
name="place_pixel",
|
||||||
|
response_time=(time.perf_counter() - start) * 1000,
|
||||||
|
response_length=len(reply) if reply else 0,
|
||||||
|
exception=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
accepted = reply is not None and reply.startswith("k|")
|
||||||
|
|
||||||
|
# Both an accepted placement (k|<next>) and a cooldown rejection
|
||||||
|
# (r|c|<next>) carry the server's authoritative cooldown end - sync
|
||||||
|
# to it either way, for the same reason _connect syncs off 's|...':
|
||||||
|
# it keeps us honest against clock drift and against several
|
||||||
|
# simulated users sharing one round-robined account, instead of
|
||||||
|
# hammering the server with attempts it will just keep rejecting.
|
||||||
|
if reply is not None and reply.startswith(("k|", "r|c|")):
|
||||||
|
next_ms = reply.rsplit("|", 1)[1]
|
||||||
|
self.next_placement_at = int(next_ms) / 1000
|
||||||
|
|
||||||
|
if not accepted and random.random() < RELOAD_ON_FAILURE_PROBABILITY:
|
||||||
|
self._reload()
|
||||||
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
|
|||||||
|
|
||||||
|
|
||||||
def _canvas_key(event_path: str) -> str:
|
def _canvas_key(event_path: str) -> str:
|
||||||
return f"place:canvas:{event_path}"
|
return f"place:{event_path}:canvas"
|
||||||
|
|
||||||
|
|
||||||
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
||||||
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
|
|||||||
"""
|
"""
|
||||||
grid = bytearray(width * height)
|
grid = bytearray(width * height)
|
||||||
count = 0
|
count = 0
|
||||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
placements = (
|
||||||
"placed_at"
|
PixelPlacement.objects.filter(event=event)
|
||||||
).values_list("x", "y", "color_index")
|
.order_by("placed_at")
|
||||||
|
.values_list("x", "y", "color_index")
|
||||||
|
)
|
||||||
for x, y, color_index in placements.iterator():
|
for x, y, color_index in placements.iterator():
|
||||||
if x >= width or y >= height:
|
if x >= width or y >= height:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
|||||||
# Simple CAS Configuration
|
# Simple CAS Configuration
|
||||||
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
||||||
|
|
||||||
|
# Enables POST /testing/login/ (see testing_auth_views.py), a
|
||||||
|
# username/password login for local-only accounts that authenticate through
|
||||||
|
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
|
||||||
|
# loadtest/locustfile.py) never sends traffic to the real CAS server at
|
||||||
|
# polylan.ch. Leave this off everywhere except a dedicated load-test
|
||||||
|
# environment - the "Create load test users" admin action on CustomUser
|
||||||
|
# (see accounts/admin.py) also hides itself without it, as a second layer
|
||||||
|
# of the same guard rail.
|
||||||
|
LOAD_TEST_LOGIN_ENABLED = (os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true")
|
||||||
|
|
||||||
# Steam API Configuration
|
# Steam API Configuration
|
||||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
||||||
|
|
||||||
@@ -329,14 +339,12 @@ STORAGES = {
|
|||||||
"BACKEND": "storages.backends.s3.S3Storage",
|
"BACKEND": "storages.backends.s3.S3Storage",
|
||||||
"OPTIONS": _s3_storage_options,
|
"OPTIONS": _s3_storage_options,
|
||||||
},
|
},
|
||||||
# Static files are collected to local disk and served by WhiteNoise in
|
# Static files are always collected to local disk and served by
|
||||||
# DEBUG (no need to push every CSS/JS tweak to S3 while developing);
|
# WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
|
||||||
# production collects straight to S3 like everything else here.
|
# likewise unconditional) - S3 is only for user-uploaded content
|
||||||
|
# ("default", above), never for the app's own CSS/JS/images.
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
if DEBUG
|
|
||||||
else "storages.backends.s3.S3Storage",
|
|
||||||
**({} if DEBUG else {"OPTIONS": _s3_storage_options}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
|
from testing_auth_views import TestLoginView
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
from market.views import market_home
|
from market.views import market_home
|
||||||
@@ -112,6 +113,12 @@ urlpatterns = [
|
|||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Load-test-only username/password login (see testing_auth_views.py) - the
|
||||||
|
# URL itself doesn't exist unless explicitly enabled, so it 404s rather than
|
||||||
|
# 403s in every environment that hasn't opted in.
|
||||||
|
if settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
urlpatterns.append(path("testing/login/", TestLoginView.as_view(), name="testing.login"))
|
||||||
|
|
||||||
# Serve media files in development
|
# Serve media files in development
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Test-only username/password login.
|
||||||
|
|
||||||
|
Lets load-testing tools (see loadtest/locustfile.py) authenticate local
|
||||||
|
Django accounts through AUTHENTICATION_BACKENDS' ModelBackend, entirely
|
||||||
|
bypassing SimpleCASBackend - so hammering this app under load never sends a
|
||||||
|
single request to the real CAS server at polylan.ch.
|
||||||
|
|
||||||
|
Only wired up in urls.py when settings.LOAD_TEST_LOGIN_ENABLED is true, and
|
||||||
|
that must stay off in production: it lets anyone who knows a local test
|
||||||
|
account's password log in without going through CAS at all. The "Create
|
||||||
|
load test users" admin action on CustomUser (see accounts/admin.py) is
|
||||||
|
hidden unless that same flag is set, as a second layer of the same guard
|
||||||
|
rail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, login
|
||||||
|
from django.http import HttpRequest, JsonResponse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class TestLoginView(View):
|
||||||
|
def post(self, request: HttpRequest):
|
||||||
|
username = request.POST.get("username")
|
||||||
|
password = request.POST.get("password")
|
||||||
|
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
return JsonResponse({"detail": "invalid credentials"}, status=401)
|
||||||
|
|
||||||
|
login(request, user)
|
||||||
|
return JsonResponse({"detail": "ok"})
|
||||||
@@ -37,10 +37,12 @@ dev = [
|
|||||||
"django-stubs-ext",
|
"django-stubs-ext",
|
||||||
"django-types",
|
"django-types",
|
||||||
"ipython",
|
"ipython",
|
||||||
|
"locust>=2.46.0",
|
||||||
"pre-commit",
|
"pre-commit",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
"pyright",
|
"pyright",
|
||||||
"ruff",
|
"ruff",
|
||||||
|
"websocket-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
|
|||||||
Reference in New Issue
Block a user