From 7bd79d1d5504f778d02b33336b7055bb169261c1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 7 Sep 2026 13:30:00 +0200 Subject: [PATCH] re-apply ruff TCH rules --- api/admin.py | 8 ++++++-- api/auth.py | 6 +++++- api/permissions.py | 10 ++++++---- club/admin.py | 8 ++++++-- .../0012_club_board_group_club_members_group.py | 6 +++++- .../migrations/0015_clubrole_alter_membership_role.py | 6 +++++- club/tests/test_club.py | 5 ++++- club/tests/test_clubrole.py | 5 ++++- club/tests/test_edit.py | 6 +++++- club/tests/test_membership.py | 5 ++++- club/tests/test_page.py | 6 +++++- club/tests/test_posters.py | 6 +++++- club/tests/test_sales.py | 5 ++++- com/forms.py | 5 ++++- com/ics_calendar.py | 11 ++++++++--- com/tests/test_api.py | 5 ++++- ...042_invert_is_manually_manageable_20250104_1742.py | 6 +++++- ...er_group_description_alter_user_groups_and_more.py | 6 +++++- core/migrations/0050_alter_sithfile_moderator.py | 6 +++++- core/templatetags/extensions.py | 6 ++++-- core/templatetags/renderer.py | 5 ++++- core/tests/test_page.py | 5 ++++- core/utils.py | 8 +++++--- core/views/files.py | 5 ++++- core/views/forms.py | 5 ++++- core/views/index.py | 8 ++++++-- core/views/mixins.py | 8 +++++--- core/views/user.py | 5 ++++- core/views/widgets/ajax_select.py | 11 +++++++---- counter/forms.py | 6 ++++-- counter/management/commands/dump_accounts.py | 5 ++++- counter/middleware.py | 5 +++-- counter/migrations/0026_alter_studentcard_customer.py | 5 ++++- .../0028_alter_producttype_comment_and_more.py | 6 +++++- .../0035_remove_selling_is_validated_and_more.py | 6 +++++- .../0036_product_created_at_product_updated_at.py | 5 ++++- counter/migrations/0039_price.py | 6 +++++- counter/tests/test_account_dump.py | 5 ++++- counter/tests/test_auto_actions.py | 5 ++++- counter/tests/test_counter.py | 5 ++++- counter/tests/test_invoices.py | 5 ++++- counter/tests/test_product_type.py | 6 +++++- counter/utils.py | 5 ++++- counter/views/cash.py | 6 +++++- counter/views/click.py | 10 +++++++--- counter/views/home.py | 5 ++++- counter/views/student_card.py | 6 +++++- eboutic/tests/test_basket.py | 7 +++++-- eboutic/tests/test_billing_info.py | 6 ++++-- election/forms.py | 5 ++++- forum/tests.py | 6 +++++- pedagogy/models.py | 1 - pyproject.toml | 7 +++++++ rootplace/tests/test_ban.py | 5 ++++- sas/forms.py | 3 ++- sas/tests/test_update_album.py | 6 ++++-- sas/views.py | 6 ++++-- sith/composer.py | 5 ++++- sith/tests.py | 7 +++++-- staticfiles/storage.py | 5 ++++- .../0015_alter_subscription_location_and_more.py | 6 +++++- .../migrations/0017_subscription_created_at.py | 6 +++++- subscription/tests/test_new_subscription.py | 6 ++++-- 63 files changed, 288 insertions(+), 88 deletions(-) diff --git a/api/admin.py b/api/admin.py index 611bdba0..5aeed933 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,11 +1,15 @@ +from typing import TYPE_CHECKING + from django.contrib import admin, messages -from django.db.models import QuerySet -from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from api.hashers import generate_key from api.models import ApiClient, ApiKey +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + @admin.register(ApiClient) class ApiClientAdmin(admin.ModelAdmin): diff --git a/api/auth.py b/api/auth.py index aac8cf40..97bd96a1 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,9 +1,13 @@ -from django.http import HttpRequest +from typing import TYPE_CHECKING + from ninja.security import APIKeyHeader from api.hashers import get_hasher from api.models import ApiClient, ApiKey +if TYPE_CHECKING: + from django.http import HttpRequest + class ApiKeyAuth(APIKeyHeader): """Authentication through client api keys.""" diff --git a/api/permissions.py b/api/permissions.py index 38377c98..8b1e4f11 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -39,15 +39,17 @@ Example: import operator from functools import reduce -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable -from django.contrib.auth.models import Permission -from django.http import HttpRequest -from ninja_extra import ControllerBase from ninja_extra.permissions import BasePermission from counter.utils import is_logged_in_counter +if TYPE_CHECKING: + from django.contrib.auth.models import Permission + from django.http import HttpRequest + from ninja_extra import ControllerBase + class IsInGroup(BasePermission): """Check that the user is in the group whose primary key is given.""" diff --git a/club/admin.py b/club/admin.py index 6d6d8bb0..a9550d92 100644 --- a/club/admin.py +++ b/club/admin.py @@ -12,12 +12,16 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from typing import TYPE_CHECKING + from django.contrib import admin -from django.forms.models import ModelForm -from django.http import HttpRequest from club.models import Club, ClubLink, ClubRole, LinkType, Membership +if TYPE_CHECKING: + from django.forms.models import ModelForm + from django.http import HttpRequest + @admin.register(Club) class ClubAdmin(admin.ModelAdmin): diff --git a/club/migrations/0012_club_board_group_club_members_group.py b/club/migrations/0012_club_board_group_club_members_group.py index a9ad8d3a..75dc86a0 100644 --- a/club/migrations/0012_club_board_group_club_members_group.py +++ b/club/migrations/0012_club_board_group_club_members_group.py @@ -1,12 +1,16 @@ # Generated by Django 4.2.16 on 2024-11-20 17:08 +from typing import TYPE_CHECKING + import django.db.models.deletion import django.db.models.functions.datetime from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import Q from django.utils.timezone import localdate +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + # Before the club role rework, the maximum free role # was the hardcoded highest non-board role MAXIMUM_FREE_ROLE = 1 diff --git a/club/migrations/0015_clubrole_alter_membership_role.py b/club/migrations/0015_clubrole_alter_membership_role.py index 82279f65..73aee1c8 100644 --- a/club/migrations/0015_clubrole_alter_membership_role.py +++ b/club/migrations/0015_clubrole_alter_membership_role.py @@ -1,10 +1,14 @@ # Generated by Django 5.2.3 on 2025-06-21 21:59 +from typing import TYPE_CHECKING + import django.db.models.deletion from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import Case, When +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + PRESIDENCY_ROLES = [10, 9] MAXIMUM_FREE_ROLE = 1 SITH_CLUB_ROLES = { diff --git a/club/tests/test_club.py b/club/tests/test_club.py index e64b4ce2..f983a641 100644 --- a/club/tests/test_club.py +++ b/club/tests/test_club.py @@ -1,9 +1,9 @@ from datetime import timedelta +from typing import TYPE_CHECKING import pytest from django.conf import settings from django.db import ProgrammingError -from django.test import Client from django.urls import reverse from django.utils.timezone import localdate from model_bakery import baker @@ -14,6 +14,9 @@ from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import User +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db def test_club_queryset_having_board_member(): diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index f6d61e5b..983c2aa4 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from typing import TYPE_CHECKING import pytest from django.contrib.auth.models import Permission @@ -14,6 +14,9 @@ from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User +if TYPE_CHECKING: + from collections.abc import Callable + def make_club(): # unittest-style tests cannot use fixture, so we create a function diff --git a/club/tests/test_edit.py b/club/tests/test_edit.py index c8a03b61..e740ea48 100644 --- a/club/tests/test_edit.py +++ b/club/tests/test_edit.py @@ -1,5 +1,6 @@ +from typing import TYPE_CHECKING + import pytest -from django.test import Client from django.urls import reverse from model_bakery import baker from pytest_django.asserts import assertRedirects @@ -7,6 +8,9 @@ from pytest_django.asserts import assertRedirects from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db def test_club_board_member_cannot_edit_club_properties(client: Client): diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index 285b50f4..a17569cc 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -1,6 +1,6 @@ import itertools -from collections.abc import Callable from datetime import timedelta +from typing import TYPE_CHECKING import pytest from bs4 import BeautifulSoup @@ -19,6 +19,9 @@ from club.tests.base import TestClub from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User +if TYPE_CHECKING: + from collections.abc import Callable + class TestMembershipQuerySet(TestClub): def test_ongoing(self): diff --git a/club/tests/test_page.py b/club/tests/test_page.py index aeefe068..ce5746e7 100644 --- a/club/tests/test_page.py +++ b/club/tests/test_page.py @@ -1,7 +1,8 @@ +from typing import TYPE_CHECKING + import pytest from aemark import markdown from bs4 import BeautifulSoup -from django.test import Client from django.urls import reverse from model_bakery import baker from pytest_django.asserts import assertHTMLEqual, assertRedirects @@ -10,6 +11,9 @@ from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import PageRev, User +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db def test_page_display_on_club_main_page(client: Client): diff --git a/club/tests/test_posters.py b/club/tests/test_posters.py index 8f9941e5..1dd029d5 100644 --- a/club/tests/test_posters.py +++ b/club/tests/test_posters.py @@ -1,5 +1,6 @@ +from typing import TYPE_CHECKING + import pytest -from django.test import Client from django.urls import reverse from model_bakery import baker @@ -7,6 +8,9 @@ from club.models import Club from com.models import Poster from core.baker_recipes import subscriber_user +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db @pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"]) diff --git a/club/tests/test_sales.py b/club/tests/test_sales.py index 457b8967..737c37ae 100644 --- a/club/tests/test_sales.py +++ b/club/tests/test_sales.py @@ -1,8 +1,8 @@ import csv import itertools +from typing import TYPE_CHECKING import pytest -from django.test import Client from django.urls import reverse from model_bakery import baker @@ -12,6 +12,9 @@ from core.models import User from counter.baker_recipes import product_recipe, sale_recipe from counter.models import Counter, Customer, Product, Selling +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db def test_sales_page_doesnt_crash(client: Client): diff --git a/com/forms.py b/com/forms.py index 4d5d0e09..bf4b5568 100644 --- a/com/forms.py +++ b/com/forms.py @@ -1,4 +1,5 @@ from datetime import date +from typing import TYPE_CHECKING from dateutil.relativedelta import relativedelta from django import forms @@ -9,11 +10,13 @@ from django.utils.translation import gettext_lazy as _ from club.models import Club from club.widgets.ajax_select import AutoCompleteSelectClub from com.models import News, NewsDate, Poster -from core.models import User from core.utils import get_end_of_semester from core.views.forms import SelectDateTime from core.views.widgets.markdown import MarkdownInput +if TYPE_CHECKING: + from core.models import User + class PosterForm(forms.ModelForm): class Meta: diff --git a/com/ics_calendar.py b/com/ics_calendar.py index d502f8fe..60d7d0c8 100644 --- a/com/ics_calendar.py +++ b/com/ics_calendar.py @@ -1,11 +1,10 @@ -from pathlib import Path +from typing import TYPE_CHECKING from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.sites.models import Site from django.contrib.syndication.views import add_domain from django.db.models import Count, OuterRef, QuerySet, Subquery -from django.http import HttpRequest from django.urls import reverse from django.utils import timezone from ical.calendar import Calendar @@ -14,7 +13,13 @@ from ical.event import Event from ical.types import Frequency, Recur from com.models import News, NewsDate -from core.models import User + +if TYPE_CHECKING: + from pathlib import Path + + from django.http import HttpRequest + + from core.models import User def as_absolute_url(url: str, request: HttpRequest | None = None) -> str: diff --git a/com/tests/test_api.py b/com/tests/test_api.py index d8c98acf..f7534b6b 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -1,11 +1,11 @@ from datetime import timedelta from pathlib import Path +from typing import TYPE_CHECKING import pytest from aemark import markdown from django.conf import settings from django.contrib.auth.models import Permission -from django.http import HttpResponse from django.test import Client, TestCase from django.urls import reverse from django.utils.timezone import now @@ -16,6 +16,9 @@ from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from core.models import User +if TYPE_CHECKING: + from django.http import HttpResponse + def accel_redirect_to_file(response: HttpResponse) -> Path | None: redirect = Path(response.headers.get("X-Accel-Redirect", "")) diff --git a/core/migrations/0042_invert_is_manually_manageable_20250104_1742.py b/core/migrations/0042_invert_is_manually_manageable_20250104_1742.py index 03eda90c..1e135967 100644 --- a/core/migrations/0042_invert_is_manually_manageable_20250104_1742.py +++ b/core/migrations/0042_invert_is_manually_manageable_20250104_1742.py @@ -1,9 +1,13 @@ # Generated by Django 4.2.17 on 2025-01-04 16:42 +from typing import TYPE_CHECKING + from django.db import migrations -from django.db.migrations.state import StateApps from django.db.models import F +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def invert_is_manually_manageable(apps: StateApps, schema_editor): """Invert `is_manually_manageable`. diff --git a/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py b/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py index 81cb2332..44cea98b 100644 --- a/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py +++ b/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py @@ -1,10 +1,14 @@ # Generated by Django 4.2.17 on 2024-12-31 13:30 +from typing import TYPE_CHECKING + import django.contrib.auth.models import django.db.models.deletion from django.conf import settings from django.db import migrations, models -from django.db.migrations.state import StateApps + +if TYPE_CHECKING: + from django.db.migrations.state import StateApps def migrate_ban_groups(apps: StateApps, schema_editor): diff --git a/core/migrations/0050_alter_sithfile_moderator.py b/core/migrations/0050_alter_sithfile_moderator.py index 0b6e9ef1..31af040e 100644 --- a/core/migrations/0050_alter_sithfile_moderator.py +++ b/core/migrations/0050_alter_sithfile_moderator.py @@ -1,11 +1,15 @@ # Generated by Django 5.2.12 on 2026-05-01 08:59 +from typing import TYPE_CHECKING + import django.db.models.deletion from django.conf import settings from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import F +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def set_updated_at(apps: StateApps, schema_editor): SithFile = apps.get_model("core", "SithFile") diff --git a/core/templatetags/extensions.py b/core/templatetags/extensions.py index 10592d8b..d74172a3 100644 --- a/core/templatetags/extensions.py +++ b/core/templatetags/extensions.py @@ -20,13 +20,15 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # -from typing import Callable +from typing import TYPE_CHECKING, Callable import honeypot.templatetags.honeypot as honeypot_filters from django.template.loader import render_to_string from jinja2 import Environment, nodes from jinja2.ext import Extension -from jinja2.parser import Parser + +if TYPE_CHECKING: + from jinja2.parser import Parser class HoneypotExtension(Extension): diff --git a/core/templatetags/renderer.py b/core/templatetags/renderer.py index 683a6238..ca843e07 100644 --- a/core/templatetags/renderer.py +++ b/core/templatetags/renderer.py @@ -23,15 +23,18 @@ # import datetime +from typing import TYPE_CHECKING import phonenumbers from aemark import markdown as md from django import template -from django.forms import BoundField from django.template.defaultfilters import stringfilter from django.utils.safestring import mark_safe from django.utils.translation import ngettext +if TYPE_CHECKING: + from django.forms import BoundField + register = template.Library() diff --git a/core/tests/test_page.py b/core/tests/test_page.py index 0a9ef5fd..b210cc30 100644 --- a/core/tests/test_page.py +++ b/core/tests/test_page.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import TYPE_CHECKING import freezegun import pytest @@ -6,7 +7,6 @@ from aemark import markdown from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth.models import Permission -from django.test import Client from django.urls import reverse from django.utils.timezone import now from model_bakery import baker @@ -16,6 +16,9 @@ from club.models import Club, Membership from core.baker_recipes import board_user, subscriber_user from core.models import AnonymousUser, Page, PageRev, User +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db class TestEditPage: diff --git a/core/utils.py b/core/utils.py index 9fb7adc8..30c0116d 100644 --- a/core/utils.py +++ b/core/utils.py @@ -17,16 +17,18 @@ from datetime import date, timedelta # Image utils from io import BytesIO -from typing import Final +from typing import TYPE_CHECKING, Final import PIL from django.conf import settings from django.core.files.base import ContentFile -from django.core.files.uploadedfile import UploadedFile -from django.http import HttpRequest from django.utils.timezone import localdate from PIL.Image import Image, Resampling +if TYPE_CHECKING: + from django.core.files.uploadedfile import UploadedFile + from django.http import HttpRequest + RED_PIXEL_PNG: Final[bytes] = ( b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" diff --git a/core/views/files.py b/core/views/files.py index 886e0ba9..6dc791d4 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -13,7 +13,7 @@ # # import mimetypes -from pathlib import Path +from typing import TYPE_CHECKING from urllib.parse import quote, urljoin # This file contains all the views that concern the page model @@ -48,6 +48,9 @@ from core.views.widgets.ajax_select import ( ) from counter.utils import is_logged_in_counter +if TYPE_CHECKING: + from pathlib import Path + def send_raw_file(path: Path) -> HttpResponse: """Send a file located in the MEDIA_ROOT diff --git a/core/views/forms.py b/core/views/forms.py index e46daf92..cfbeac69 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -22,8 +22,8 @@ # import re from copy import copy -from datetime import date, datetime from io import BytesIO +from typing import TYPE_CHECKING from captcha.fields import CaptchaField from django import forms @@ -59,6 +59,9 @@ from core.views.widgets.ajax_select import ( ) from core.views.widgets.markdown import MarkdownInput +if TYPE_CHECKING: + from datetime import date, datetime + # Widgets diff --git a/core/views/index.py b/core/views/index.py index b1393d8c..8de9535b 100644 --- a/core/views/index.py +++ b/core/views/index.py @@ -22,11 +22,11 @@ # # +from typing import TYPE_CHECKING + from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import F -from django.db.models.query import QuerySet -from django.http import HttpRequest from django.shortcuts import get_object_or_404, redirect from django.views.generic import ListView, TemplateView @@ -34,6 +34,10 @@ from club.models import Club from core.models import Notification, User from core.schemas import UserFilterSchema +if TYPE_CHECKING: + from django.db.models.query import QuerySet + from django.http import HttpRequest + class NotificationList(LoginRequiredMixin, ListView): model = Notification diff --git a/core/views/mixins.py b/core/views/mixins.py index 7ba92535..f0403662 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -1,14 +1,16 @@ import copy import inspect -from typing import Any, ClassVar, LiteralString, Protocol, Unpack +from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack from django.core.exceptions import ImproperlyConfigured -from django.http import HttpRequest, HttpResponse from django.template.loader import render_to_string -from django.utils.safestring import SafeString from django.views import View from django.views.generic.base import ContextMixin, TemplateResponseMixin +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + from django.utils.safestring import SafeString + class TabedViewMixin(View): """Basic functions for displaying tabs in the template.""" diff --git a/core/views/user.py b/core/views/user.py index 00d5610a..31462551 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -27,6 +27,7 @@ from datetime import timedelta # This file contains all the views that concern the user model from operator import itemgetter from smtplib import SMTPException +from typing import TYPE_CHECKING from django.contrib import messages from django.contrib.auth import login, views @@ -43,7 +44,6 @@ from django.shortcuts import get_object_or_404, redirect from django.template.loader import render_to_string from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator -from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.views.decorators.http import require_POST from django.views.generic import ( @@ -73,6 +73,9 @@ from counter.models import Refilling, Selling from eboutic.models import Invoice from trombi.views import UserTrombiForm +if TYPE_CHECKING: + from django.utils.safestring import SafeString + class SithLoginView(views.LoginView): """The login View.""" diff --git a/core/views/widgets/ajax_select.py b/core/views/widgets/ajax_select.py index 37e03971..8243965a 100644 --- a/core/views/widgets/ajax_select.py +++ b/core/views/widgets/ajax_select.py @@ -1,15 +1,18 @@ -from collections.abc import Collection -from typing import Any +from typing import TYPE_CHECKING, Any from django.contrib.staticfiles.storage import staticfiles_storage -from django.db.models import Model, QuerySet from django.forms import Select, SelectMultiple -from ninja import ModelSchema from pydantic import TypeAdapter from core.models import Group, SithFile, User from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema +if TYPE_CHECKING: + from collections.abc import Collection + + from django.db.models import Model, QuerySet + from ninja import ModelSchema + class AutoCompleteSelectMixin: component_name = "autocomplete-select" diff --git a/counter/forms.py b/counter/forms.py index 018b7ed2..877c0e79 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -3,7 +3,7 @@ import math import uuid from collections import defaultdict from datetime import date, datetime, timezone -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from dateutil.relativedelta import relativedelta from django import forms @@ -11,7 +11,6 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet -from django.http import HttpRequest from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -59,6 +58,9 @@ from counter.widgets.ajax_select import ( AutoCompleteSelectProduct, ) +if TYPE_CHECKING: + from django.http import HttpRequest + class BillingInfoForm(forms.ModelForm): class Meta: diff --git a/counter/management/commands/dump_accounts.py b/counter/management/commands/dump_accounts.py index 351c2f21..b3ea5777 100644 --- a/counter/management/commands/dump_accounts.py +++ b/counter/management/commands/dump_accounts.py @@ -1,5 +1,5 @@ -from collections.abc import Iterable from operator import attrgetter +from typing import TYPE_CHECKING from django.conf import settings from django.core.mail import send_mass_mail @@ -13,6 +13,9 @@ from django.utils.translation import gettext as _ from core.models import User, UserQuerySet from counter.models import AccountDump, Counter, Customer, Product, Selling +if TYPE_CHECKING: + from collections.abc import Iterable + class Command(BaseCommand): """Effectively dump the inactive users. diff --git a/counter/middleware.py b/counter/middleware.py index 5b9efb43..8f195902 100644 --- a/counter/middleware.py +++ b/counter/middleware.py @@ -1,13 +1,14 @@ from typing import TYPE_CHECKING, Callable -from django.http import HttpRequest, HttpResponse from django.utils.functional import SimpleLazyObject -from core.models import User from counter.models import Permanency if TYPE_CHECKING: from django.contrib.sessions.backends.base import SessionBase + from django.http import HttpRequest, HttpResponse + + from core.models import User SESSION_PERMANENCES_KEY = "permanence_ids" diff --git a/counter/migrations/0026_alter_studentcard_customer.py b/counter/migrations/0026_alter_studentcard_customer.py index f1f5cd49..66b16650 100644 --- a/counter/migrations/0026_alter_studentcard_customer.py +++ b/counter/migrations/0026_alter_studentcard_customer.py @@ -1,11 +1,14 @@ # Generated by Django 4.2.17 on 2024-12-08 13:30 from operator import attrgetter +from typing import TYPE_CHECKING import django.db.models.deletion from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import Count +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def delete_duplicates(apps: StateApps, schema_editor): """Delete cards of users with more than one student cards. diff --git a/counter/migrations/0028_alter_producttype_comment_and_more.py b/counter/migrations/0028_alter_producttype_comment_and_more.py index f7fabb83..e04efae4 100644 --- a/counter/migrations/0028_alter_producttype_comment_and_more.py +++ b/counter/migrations/0028_alter_producttype_comment_and_more.py @@ -1,7 +1,11 @@ # Generated by Django 4.2.17 on 2024-12-15 17:53 +from typing import TYPE_CHECKING + from django.db import migrations, models -from django.db.migrations.state import StateApps + +if TYPE_CHECKING: + from django.db.migrations.state import StateApps def move_priority_to_order(apps: StateApps, schema_editor): diff --git a/counter/migrations/0035_remove_selling_is_validated_and_more.py b/counter/migrations/0035_remove_selling_is_validated_and_more.py index a8a2e115..3c9b2543 100644 --- a/counter/migrations/0035_remove_selling_is_validated_and_more.py +++ b/counter/migrations/0035_remove_selling_is_validated_and_more.py @@ -1,9 +1,13 @@ # Generated by Django 5.2.8 on 2025-11-19 17:59 +from typing import TYPE_CHECKING + from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import Case, When +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def migrate_selling_payment_method(apps: StateApps, schema_editor): # 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it diff --git a/counter/migrations/0036_product_created_at_product_updated_at.py b/counter/migrations/0036_product_created_at_product_updated_at.py index 5fe622f2..58ddd838 100644 --- a/counter/migrations/0036_product_created_at_product_updated_at.py +++ b/counter/migrations/0036_product_created_at_product_updated_at.py @@ -1,13 +1,16 @@ # Generated by Django 5.2.8 on 2026-02-10 15:40 from operator import attrgetter +from typing import TYPE_CHECKING import django.utils.timezone from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import OuterRef, Subquery from counter.models import Selling +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def apply_product_history_dates(apps: StateApps, schema_editor): """Approximate a posteriori the value of created_at and updated_at.""" diff --git a/counter/migrations/0039_price.py b/counter/migrations/0039_price.py index d98159ea..72d68454 100644 --- a/counter/migrations/0039_price.py +++ b/counter/migrations/0039_price.py @@ -1,11 +1,15 @@ # Generated by Django 5.2.11 on 2026-02-18 13:30 +from typing import TYPE_CHECKING + import django.db.models.deletion from django.db import migrations, models -from django.db.migrations.state import StateApps import counter.fields +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def migrate_prices(apps: StateApps, schema_editor): Product = apps.get_model("counter", "Product") diff --git a/counter/tests/test_account_dump.py b/counter/tests/test_account_dump.py index 0d41576e..77c42271 100644 --- a/counter/tests/test_account_dump.py +++ b/counter/tests/test_account_dump.py @@ -1,5 +1,5 @@ -from collections.abc import Iterable from datetime import timedelta +from typing import TYPE_CHECKING import freezegun import pytest @@ -17,6 +17,9 @@ from counter.management.commands.dump_warning_mail import Command as WarningComm from counter.models import AccountDump, Customer, Refilling, Selling from subscription.models import Subscription +if TYPE_CHECKING: + from collections.abc import Iterable + class TestAccountDump(TestCase): @classmethod diff --git a/counter/tests/test_auto_actions.py b/counter/tests/test_auto_actions.py index 6b054724..c47fba61 100644 --- a/counter/tests/test_auto_actions.py +++ b/counter/tests/test_auto_actions.py @@ -1,9 +1,9 @@ import json from datetime import timedelta +from typing import TYPE_CHECKING import pytest from django.conf import settings -from django.test import Client from django.urls import reverse from django.utils.timezone import now from django_celery_beat.models import ClockedSchedule @@ -18,6 +18,9 @@ from counter.forms import ( ) from counter.models import Product, ProductType, ScheduledProductAction +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db def test_edit_product(client: Client): diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index deba1f1f..581b5b07 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -15,6 +15,7 @@ from dataclasses import asdict, dataclass from datetime import timedelta from decimal import Decimal +from typing import TYPE_CHECKING import pytest from bs4 import BeautifulSoup @@ -22,7 +23,6 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import Permission, make_password from django.contrib.messages import DEFAULT_LEVELS, get_messages -from django.http import HttpResponse from django.shortcuts import resolve_url from django.test import Client, TestCase from django.urls import reverse @@ -48,6 +48,9 @@ from counter.models import ( Selling, ) +if TYPE_CHECKING: + from django.http import HttpResponse + def set_age(user: User, age: int): user.date_of_birth = localdate().replace(year=localdate().year - age) diff --git a/counter/tests/test_invoices.py b/counter/tests/test_invoices.py index 8a9cc63a..0228d29d 100644 --- a/counter/tests/test_invoices.py +++ b/counter/tests/test_invoices.py @@ -1,10 +1,10 @@ from datetime import date, datetime +from typing import TYPE_CHECKING import pytest from dateutil.relativedelta import relativedelta from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError -from django.test import Client from django.urls import reverse from django.utils.timezone import now from model_bakery import baker @@ -16,6 +16,9 @@ from counter.baker_recipes import sale_recipe from counter.forms import InvoiceCallForm from counter.models import Customer, InvoiceCall, Selling +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db @pytest.mark.parametrize( diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py index ad74d619..f8e5498e 100644 --- a/counter/tests/test_product_type.py +++ b/counter/tests/test_product_type.py @@ -1,6 +1,7 @@ +from typing import TYPE_CHECKING + import pytest from django.conf import settings -from django.test import Client from django.urls import reverse from model_bakery import baker, seq @@ -8,6 +9,9 @@ from core.baker_recipes import board_user, subscriber_user from core.models import Group, User from counter.models import ProductType +if TYPE_CHECKING: + from django.test import Client + @pytest.fixture def product_types(db) -> list[ProductType]: diff --git a/counter/utils.py b/counter/utils.py index e91ecfbe..03bde72f 100644 --- a/counter/utils.py +++ b/counter/utils.py @@ -1,8 +1,11 @@ +from typing import TYPE_CHECKING from urllib.parse import urlparse -from django.http import HttpRequest from django.urls import resolve +if TYPE_CHECKING: + from django.http import HttpRequest + def is_logged_in_counter(request: HttpRequest) -> bool: """Check if the request is sent from a device logged to a counter. diff --git a/counter/views/cash.py b/counter/views/cash.py index 3a4ace70..db703996 100644 --- a/counter/views/cash.py +++ b/counter/views/cash.py @@ -13,6 +13,8 @@ # # +from typing import TYPE_CHECKING + from django import forms from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -22,7 +24,6 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView from django.views.generic.edit import BaseFormView, UpdateView -from core.models import User from counter.forms import CashSummaryFormBase from counter.models import ( CashRegisterSummary, @@ -35,6 +36,9 @@ from counter.views.mixins import ( CounterTabsMixin, ) +if TYPE_CHECKING: + from core.models import User + class CashRegisterSummaryForm(forms.Form): """Provide the cash summary form.""" diff --git a/counter/views/click.py b/counter/views/click.py index 9ec678ae..f13444ef 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -14,6 +14,7 @@ # import random from collections import defaultdict +from typing import TYPE_CHECKING from django.contrib import messages from django.core.exceptions import PermissionDenied @@ -22,14 +23,11 @@ from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, resolve_url from django.urls import reverse -from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.views.generic import CreateView, FormView from django.views.generic.detail import SingleObjectMixin -from ninja.main import HttpRequest from core.auth.mixins import CanViewMixin -from core.models import User from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import BasketForm, RefillForm from counter.models import ( @@ -44,6 +42,12 @@ from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin from counter.views.student_card import StudentCardFormFragment +if TYPE_CHECKING: + from django.utils.safestring import SafeString + from ninja.main import HttpRequest + + from core.models import User + def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User: if counter.type != "BAR": diff --git a/counter/views/home.py b/counter/views/home.py index 1ee8c847..c643cdb1 100644 --- a/counter/views/home.py +++ b/counter/views/home.py @@ -13,6 +13,7 @@ # # from datetime import timedelta +from typing import TYPE_CHECKING from django.conf import settings from django.core.exceptions import PermissionDenied @@ -21,7 +22,6 @@ from django.http import HttpRequest, HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse from django.utils import timezone -from django.utils.safestring import SafeString from django.views.decorators.http import require_POST from django.views.generic import DetailView from django.views.generic.detail import SingleObjectMixin @@ -35,6 +35,9 @@ from counter.models import Counter, Permanency from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin +if TYPE_CHECKING: + from django.utils.safestring import SafeString + class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView): model = Counter diff --git a/counter/views/student_card.py b/counter/views/student_card.py index 9aef2e11..8f3bb033 100644 --- a/counter/views/student_card.py +++ b/counter/views/student_card.py @@ -13,11 +13,12 @@ # # +from typing import TYPE_CHECKING + from django.core.exceptions import PermissionDenied from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 from django.urls import reverse -from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.views.generic.edit import DeleteView, FormView @@ -27,6 +28,9 @@ from counter.forms import StudentCardForm from counter.models import Customer, StudentCard from counter.utils import is_logged_in_counter +if TYPE_CHECKING: + from django.utils.safestring import SafeString + class StudentCardDeleteView(DeleteView): """View used to delete a card from a user. This is a fragment view !""" diff --git a/eboutic/tests/test_basket.py b/eboutic/tests/test_basket.py index 2dc296d1..e57bd792 100644 --- a/eboutic/tests/test_basket.py +++ b/eboutic/tests/test_basket.py @@ -1,13 +1,12 @@ import re from datetime import datetime, timezone +from typing import TYPE_CHECKING import freezegun import pytest from bs4 import BeautifulSoup from django.conf import settings -from django.http import HttpResponse from django.test import TestCase -from django.test.client import Client from django.urls import reverse from django.utils.timezone import localdate, now from model_bakery import baker @@ -31,6 +30,10 @@ from counter.models import ( from counter.tests.test_counter import BasketItem from eboutic.models import Basket +if TYPE_CHECKING: + from django.http import HttpResponse + from django.test.client import Client + @pytest.mark.django_db def test_get_eboutic(): diff --git a/eboutic/tests/test_billing_info.py b/eboutic/tests/test_billing_info.py index ea37ec66..87146ab7 100644 --- a/eboutic/tests/test_billing_info.py +++ b/eboutic/tests/test_billing_info.py @@ -1,7 +1,6 @@ -from typing import Callable +from typing import TYPE_CHECKING, Callable import pytest -from django.test import Client from django.urls import reverse from model_bakery import baker from pytest_django.asserts import assertRedirects @@ -10,6 +9,9 @@ from core.baker_recipes import subscriber_user from core.models import User from counter.models import BillingInfo +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db class TestBillingInfo: diff --git a/election/forms.py b/election/forms.py index 61c1eaef..c4f19404 100644 --- a/election/forms.py +++ b/election/forms.py @@ -1,6 +1,7 @@ from datetime import timedelta from itertools import groupby, islice from operator import attrgetter +from typing import TYPE_CHECKING from django import forms from django.conf import settings @@ -13,7 +14,6 @@ from django.utils.translation import gettext_lazy as _ from club.forms import ClubRoleChoiceField from club.models import ClubRole, Membership from club.widgets.ajax_select import AutoCompleteSelectMultipleClub -from core.models import User from core.views.forms import SelectDateTime from core.views.widgets.ajax_select import ( AutoCompleteSelect, @@ -23,6 +23,9 @@ from core.views.widgets.ajax_select import ( from core.views.widgets.markdown import MarkdownInput from election.models import Candidature, Election, ElectionList, Role +if TYPE_CHECKING: + from core.models import User + class LimitedCheckboxField(forms.ModelMultipleChoiceField): """A `ModelMultipleChoiceField`, with a max limit of selectable inputs.""" diff --git a/forum/tests.py b/forum/tests.py index 6ce24c1b..ab5bf5c7 100644 --- a/forum/tests.py +++ b/forum/tests.py @@ -13,15 +13,19 @@ # # +from typing import TYPE_CHECKING + import pytest from django.conf import settings -from django.test import Client from django.urls import reverse from pytest_django.asserts import assertRedirects from core.models import User from forum.models import Forum, ForumMessage, ForumTopic +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db class TestTopicCreation: diff --git a/pedagogy/models.py b/pedagogy/models.py index f9731d86..7333037a 100644 --- a/pedagogy/models.py +++ b/pedagogy/models.py @@ -91,7 +91,6 @@ class UE(models.Model): # All texts about the UE title = models.CharField(_("title"), max_length=300) - manager = models.CharField(_("ue manager"), max_length=300) objectives = models.TextField(_("objectives")) program = models.TextField(_("program")) skills = models.TextField(_("skills")) diff --git a/pyproject.toml b/pyproject.toml index de3e43bd..3e944f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,13 @@ ignore = [ "RUF012" # mutable class attributes. This rule doesn't integrate well with django ] +[tool.ruff.lint.per-file-ignores] +# schemas and API routes are built upon ninja and pydantic, +# which heavily rely on type annotations at runtime. +# Systematically using TYPE_CHECKING blocks in those files is NOT safe +"schemas.py" = ["TCH"] +"api.py" = ["TCH"] + [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/rootplace/tests/test_ban.py b/rootplace/tests/test_ban.py index 4616630e..e4d3e336 100644 --- a/rootplace/tests/test_ban.py +++ b/rootplace/tests/test_ban.py @@ -1,8 +1,8 @@ from datetime import datetime, timedelta +from typing import TYPE_CHECKING import pytest from django.contrib.auth.models import Permission -from django.test import Client from django.urls import reverse from django.utils.timezone import localtime from model_bakery import baker @@ -10,6 +10,9 @@ from pytest_django.asserts import assertRedirects from core.models import BanGroup, User, UserBan +if TYPE_CHECKING: + from django.test import Client + @pytest.fixture def operator(db) -> User: diff --git a/sas/forms.py b/sas/forms.py index 4621e8ba..1c051e18 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -9,7 +9,6 @@ from django.utils.timezone import get_current_timezone from django.utils.translation import gettext_lazy as _ from PIL import Image -from core.models import User from core.utils import resize_image from core.views import MultipleImageField from core.views.forms import SelectDate @@ -20,6 +19,8 @@ from sas.widgets.ajax_select import AutoCompleteSelectAlbum if TYPE_CHECKING: from django.db.models.fields.files import FieldFile + from core.models import User + class AlbumCreateForm(forms.ModelForm): class Meta: diff --git a/sas/tests/test_update_album.py b/sas/tests/test_update_album.py index cc3fbd7a..772744ef 100644 --- a/sas/tests/test_update_album.py +++ b/sas/tests/test_update_album.py @@ -1,14 +1,13 @@ import random import string from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, Callable from unittest.mock import patch import pytest from django.conf import settings from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import Client from django.urls import reverse from django.utils.datastructures import MultiValueDict from django.utils.timezone import localdate @@ -23,6 +22,9 @@ from sas.baker_recipes import picture_recipe from sas.forms import AlbumEditForm from sas.models import Album +if TYPE_CHECKING: + from django.test import Client + @pytest.fixture def sas_root(db) -> Album: diff --git a/sas/views.py b/sas/views.py index 5e5e5825..5d61813e 100644 --- a/sas/views.py +++ b/sas/views.py @@ -12,7 +12,7 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Any +from typing import TYPE_CHECKING, Any from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -21,7 +21,6 @@ from django.db.models import Count, OuterRef, Subquery from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.utils.safestring import SafeString from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic.edit import FormView, UpdateView @@ -40,6 +39,9 @@ from sas.forms import ( ) from sas.models import Album, PeoplePictureRelation, Picture +if TYPE_CHECKING: + from django.utils.safestring import SafeString + class AlbumCreateFragment(FragmentMixin, CreateView): model = Album diff --git a/sith/composer.py b/sith/composer.py index dd3d648a..4f5445eb 100644 --- a/sith/composer.py +++ b/sith/composer.py @@ -2,12 +2,15 @@ import logging import signal import subprocess import sys -from pathlib import Path +from typing import TYPE_CHECKING import psutil from sith import settings +if TYPE_CHECKING: + from pathlib import Path + def get_pid_file(procfile: Path) -> Path: """Get the PID file associated with a procfile""" diff --git a/sith/tests.py b/sith/tests.py index 5da96f1d..94a49054 100644 --- a/sith/tests.py +++ b/sith/tests.py @@ -1,11 +1,14 @@ from contextlib import nullcontext as does_not_raise +from typing import TYPE_CHECKING import pytest -from _pytest.raises import RaisesExc -from django.test import Client from django.test.utils import override_settings from django.urls import reverse +if TYPE_CHECKING: + from _pytest.raises import RaisesExc + from django.test import Client + @pytest.mark.django_db @pytest.mark.parametrize( diff --git a/staticfiles/storage.py b/staticfiles/storage.py index 442de568..983ec654 100644 --- a/staticfiles/storage.py +++ b/staticfiles/storage.py @@ -1,14 +1,17 @@ from pathlib import Path +from typing import TYPE_CHECKING from django.conf import settings from django.contrib.staticfiles.finders import find from django.contrib.staticfiles.storage import ( ManifestStaticFilesStorage, ) -from django.core.files.storage import Storage from staticfiles.processors import JS, JSBundler, Scss +if TYPE_CHECKING: + from django.core.files.storage import Storage + class ManifestPostProcessingStorage(ManifestStaticFilesStorage): def url(self, name: str, *, force: bool = False) -> str: diff --git a/subscription/migrations/0015_alter_subscription_location_and_more.py b/subscription/migrations/0015_alter_subscription_location_and_more.py index 6aed659e..21ad2478 100644 --- a/subscription/migrations/0015_alter_subscription_location_and_more.py +++ b/subscription/migrations/0015_alter_subscription_location_and_more.py @@ -1,7 +1,11 @@ # Generated by Django 5.2.3 on 2025-09-08 05:38 +from typing import TYPE_CHECKING + from django.db import migrations, models -from django.db.migrations.state import StateApps + +if TYPE_CHECKING: + from django.db.migrations.state import StateApps def rename_enums(apps: StateApps, schema_editor): diff --git a/subscription/migrations/0017_subscription_created_at.py b/subscription/migrations/0017_subscription_created_at.py index 7aeda459..27e5b23a 100644 --- a/subscription/migrations/0017_subscription_created_at.py +++ b/subscription/migrations/0017_subscription_created_at.py @@ -1,12 +1,16 @@ # Generated by Django 5.2.17 on 2026-09-01 08:44 +from typing import TYPE_CHECKING + import django.utils.timezone from django.db import migrations, models -from django.db.migrations.state import StateApps from django.db.models import F, Value from django.db.models.functions import Least from django.utils.timezone import now +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + def make_default_creation_date(apps: StateApps, schema_editor): Subscription = apps.get_model("subscription", "Subscription") diff --git a/subscription/tests/test_new_subscription.py b/subscription/tests/test_new_subscription.py index a02a1d49..4895121b 100644 --- a/subscription/tests/test_new_subscription.py +++ b/subscription/tests/test_new_subscription.py @@ -1,13 +1,12 @@ """Tests focused on testing subscription creation""" from datetime import date, timedelta -from typing import Callable +from typing import TYPE_CHECKING, Callable import pytest from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import Permission -from django.test import Client from django.urls import reverse from django.utils.timezone import localdate from model_bakery import baker @@ -19,6 +18,9 @@ from counter.models import Customer from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm from subscription.models import Subscription +if TYPE_CHECKING: + from django.test import Client + @pytest.mark.django_db @pytest.mark.parametrize(