diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..19f0285 --- /dev/null +++ b/.containerignore @@ -0,0 +1,21 @@ +config.yaml +run.py +.git +.venv +.env +.ruff_cache +.pytest_cache +frontend +.github +docs +Makefile +.pre-commit-config.yaml +LICENSE +.gitignore +.containerignore +.gitmodules +podman-compose.yaml +__pycache__ +*.py[codz] +**/__pycache__ +**/*.py[codz] diff --git a/.env.example b/.env.example index 93883f9..0473e9f 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,8 @@ SECRET_KEY=django-insecure-my-local-dev-secret-key # --- Infrastructure (REQUIRED) --- # Use a single URL for database and Redis connections. # Format: driver://user:password@host:port/dbname -DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview -REDIS__URL=redis://localhost:6379/0 +DATABASE__URL=postgres://admin:test@db:5432/coursereview +REDIS__URL=redis://cache:6379/0 # --- External Services Secrets (REQUIRED) --- TURNSTILE_SECRET_KEY=dummy0 diff --git a/.github/workflows/bot.yaml b/.github/workflows/bot.yaml index 3a11154..3937c25 100644 --- a/.github/workflows/bot.yaml +++ b/.github/workflows/bot.yaml @@ -36,7 +36,7 @@ on: public: pull_request: branches: - - '*' + - "*" types: [opened, reopened] pull_request_review: types: [edited, dismissed, submitted] @@ -46,7 +46,7 @@ on: types: [assigned, opened, synchronize, reopened] push: branches: - - '*' + - "*" registry_package: types: [published] release: diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..0b4dde5 --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,54 @@ +name: Publish container image + +on: + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/tech-ji/coursereview + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=sha- + type=ref,event=tag + labels: | + org.opencontainers.image.source=https://github.com/Tech-JI/CourseReview + org.opencontainers.image.description=CourseReview Django application + + - name: Build and push image + id: push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Containerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Show digest + run: | + echo "Digest: ${{ steps.push.outputs.digest }}" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..fcf390c --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,78 @@ +name: pytest + +on: + push: + branches: ["dev", "main"] + pull_request: + branches: ["dev", "main"] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + services: + db: + image: postgres:18-alpine + env: + POSTGRES_DB: coursereview + POSTGRES_USER: admin + POSTGRES_PASSWORD: test + ports: + - "5432:5432" + options: >- + --health-cmd "pg_isready -U admin -d coursereview" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + cache: + image: valkey/valkey:9-alpine + ports: + - "6379:6379" + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + SECRET_KEY: 02247f40-a769-4c49-9178-4c038048e7ad + DATABASE__URL: postgres://admin:test@db:5432/coursereview + REDIS__URL: redis://cache:6379/0 + + TURNSTILE_SECRET_KEY: dummy0 + + QUEST__SIGNUP__API_KEY: dummy1 + QUEST__SIGNUP__URL: https://wj.sjtu.edu.cn/q/dummy0 + QUEST__SIGNUP__QUESTIONID: "10000000" + + QUEST__LOGIN__API_KEY: dummy2 + QUEST__LOGIN__URL: https://wj.sjtu.edu.cn/q/dummy1 + QUEST__LOGIN__QUESTIONID: "10000001" + + QUEST__RESET__API_KEY: dummy3 + QUEST__RESET__URL: https://wj.sjtu.edu.cn/q/dummy2 + QUEST__RESET__QUESTIONID: "10000002" + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: pyproject.toml + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen --group dev + + - name: Run pytest + run: python run.py test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87696b3..eb5e991 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,28 @@ -repos: +default_language_version: + python: 3.14 +repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - id: check-added-large-files + - id: check-ast + - id: check-builtin-literals + - id: check-case-conflict + - id: check-illegal-windows-names + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: detect-private-key + - id: end-of-file-fixer + - id: no-commit-to-branch + - id: pretty-format-json + - id: requirements-txt-fixer + - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.5 + rev: v0.15.2 hooks: - id: ruff-check types_or: [python, pyi] diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..cb6ee1c --- /dev/null +++ b/Containerfile @@ -0,0 +1,20 @@ +FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder + +WORKDIR /app + +COPY . /app + +RUN UV_PROJECT_ENVIRONMENT=/usr/local \ + uv sync --project=/app --frozen --compile-bytecode --no-dev --no-editable --no-managed-python + +FROM gcr.io/distroless/base-debian13:nonroot + +COPY --from=builder /usr/local /usr/local + +COPY --from=builder /app /app + +WORKDIR /app + +USER nonroot + +ENTRYPOINT ["python", "scripts/entrypoint.py"] diff --git a/Makefile b/Makefile deleted file mode 100644 index 8622be3..0000000 --- a/Makefile +++ /dev/null @@ -1,81 +0,0 @@ -.PHONY: run dev-frontend clean collect install-frontend format format-backend format-frontend lint lint-backend lint-frontend makemigrations migrate shell createsuperuser help - -# Default target when 'make' is run without arguments -.DEFAULT_GOAL := help - -help: - @echo "Available commands:" - @echo " run - Starts the Django development server (formats backend code first)" - @echo " dev-frontend - Starts the frontend development server (formats frontend code first)" - @echo " clean - Clears Django session data" - @echo " collect - Collects Django static files" - @echo " install-frontend - Installs frontend dependencies using bun" - @echo " format - Formats both backend (Python) and frontend (JS/TS/CSS) code" - @echo " format-backend - Formats Python code using ruff check and format" - @echo " format-frontend - Formats frontend code using prettier" - @echo " lint - Lints both backend (Python) and frontend (JS/TS/CSS) code" - @echo " lint-backend - Lints Python code using ruff" - @echo " lint-frontend - Lints frontend code using eslint" - @echo " makemigrations - Creates new Django model migrations" - @echo " migrate - Applies Django database migrations" - @echo " shell - Opens a Django shell" - @echo " createsuperuser - Creates a Django superuser account" - -run: format-backend - @echo "Starting Django development server..." - uv run manage.py runserver - -dev-frontend: format-frontend - @echo "Starting frontend dev server from frontend/ folder..." - cd frontend && bun run dev - -clean: - @echo "Clearing Django session data..." - uv run manage.py clearsession - -collect: - @echo "Collecting Django static files (confirming 'yes')..." - echo 'yes' | uv run manage.py collectstatic - -install-frontend: - @echo "Installing frontend dependencies with bun..." - cd frontend && bun install - -format: format-backend format-frontend - @echo "All code formatted successfully!" - -format-backend: - @echo "Formatting backend (Python) code with ruff check and format..." - uv run ruff check --select I . --fix && \ - uv run ruff format - -format-frontend: - @echo "Formatting frontend code with prettier..." - cd frontend && bun run format | grep -v -F '(unchanged)' || true - -lint: lint-backend lint-frontend - @echo "All code linted successfully!" - -lint-backend: format-backend - @echo "Linting backend (Python) code with ruff..." - uv run ruff check - -lint-frontend: format-frontend - @echo "Linting frontend code with eslint..." - cd frontend && bun run lint - -makemigrations: - @echo "Creating Django database migrations..." - uv run manage.py makemigrations - -migrate: - @echo "Applying Django database migrations..." - uv run manage.py migrate - -shell: - @echo "Opening Django shell..." - uv run manage.py shell - -createsuperuser: - @echo "Creating Django superuser account..." - uv run manage.py createsuperuser diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 4c60c36..3e63695 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -46,7 +46,7 @@ def get_survey_details(action: str) -> dict[str, Any] | None: try: question_id = int(action_details.get("QUESTIONID")) - except (ValueError, TypeError): + except ValueError, TypeError: logger.error( "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action ) diff --git a/apps/auth/views.py b/apps/auth/views.py index df1672d..01aafb0 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -240,7 +240,7 @@ def verify_callback_api(request): otp_data = json.loads(otp_data_raw.decode("utf-8")) expected_temp_token = otp_data.get("temp_token") initiated_at = otp_data.get("initiated_at") - except (json.JSONDecodeError, AttributeError): + except json.JSONDecodeError, AttributeError: logger.error("Invalid OTP data format in verify_callback_api") return Response({"error": "Invalid OTP data format"}, status=401) @@ -268,7 +268,7 @@ def verify_callback_api(request): status=401, ) - except (ValueError, TypeError): + except ValueError, TypeError: logger.error("Error parsing submission timestamp") return Response({"error": "Invalid submission timestamp"}, status=401) diff --git a/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py new file mode 100644 index 0000000..d032ebc --- /dev/null +++ b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.8 on 2026-01-19 02:11 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0011_remove_course_difficulty_score_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="vote", + index=models.Index( + fields=["course", "category", "value"], + name="web_vote_course__b117a9_idx", + ), + ), + ] diff --git a/apps/web/models/course.py b/apps/web/models/course.py index dd2e8a4..da03f31 100644 --- a/apps/web/models/course.py +++ b/apps/web/models/course.py @@ -41,7 +41,7 @@ def search(self, query): elif len(department_or_query) not in self.DEPARTMENT_LENGTHS: # must be query, too long to be department. ignore numbers we may # have. e.g. "Introduction" - return Course.objects.filter(title__icontains=department_or_query) + return Course.objects.filter(course_title__icontains=department_or_query) # elif number and subnumber: # # course with number and subnumber # # e.g. COSC 089.01 @@ -140,7 +140,7 @@ class Meta: ] def __unicode__(self): - return "{}: {}".format(self.short_name(), self.title) + return "{}: {}".format(self.short_name(), self.course_title) def get_absolute_url(self): return reverse("course_detail", args=[self.id]) diff --git a/apps/web/tests/conftest.py b/apps/web/tests/conftest.py new file mode 100644 index 0000000..f543ec8 --- /dev/null +++ b/apps/web/tests/conftest.py @@ -0,0 +1,134 @@ +import pytest +from django.conf import settings +from django.urls import reverse +from rest_framework.test import APIClient +from apps.web.tests import factories + +# ------------------------------------------------------------------------- +# 1. Clients & Authentication +# ------------------------------------------------------------------------- + + +@pytest.fixture +def base_client(): + """Returns an unauthenticated API client.""" + return APIClient() + + +@pytest.fixture +def user(db): + """Returns a saved user instance.""" + return factories.UserFactory() + + +@pytest.fixture +def auth_client(user, base_client): + """Returns an API client authenticated as the 'user' fixture.""" + base_client.force_authenticate(user=user) + return base_client + + +# ------------------------------------------------------------------------- +# 2. Data Fixtures (Models) +# ------------------------------------------------------------------------- + + +@pytest.fixture +def course(db): + """Returns a saved course instance.""" + return factories.CourseFactory() + + +@pytest.fixture +def course_batch(db): + """Returns a batch of 3 general courses.""" + return factories.CourseFactory.create_batch(3) + + +@pytest.fixture +def department_mixed_courses(db): + """Returns a mixed set of courses for filtering/sorting tests.""" + # Note: Using course_title to match current course model field + return [ + factories.CourseFactory( + department="MATH", + course_title="Honors Calculus II", + course_code="MATH1560J", + ), + factories.CourseFactory( + department="MATH", course_title="Calculus II", course_code="MATH1160J" + ), + factories.CourseFactory( + department="CHEM", course_title="Chemistry", course_code="CHEM2100J" + ), + ] + + +@pytest.fixture +def review(db, course, user, min_len): + """Returns a saved review instance belonging to 'user'.""" + return factories.ReviewFactory(course=course, user=user, comments="a" * min_len) + + +@pytest.fixture +def other_review(db): + """Returns a review belonging to a different user for security testing.""" + from apps.web.tests.factories import UserFactory, ReviewFactory + + return ReviewFactory(user=UserFactory()) + + +@pytest.fixture +def course_factory(db): + """Access the factory class directly for custom batch creation.""" + return factories.CourseFactory + + +# ------------------------------------------------------------------------- +# 3. Validation & Payloads +# ------------------------------------------------------------------------- + + +@pytest.fixture +def min_len(): + """Retrieves the minimum comment length from project settings.""" + return settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"] + + +@pytest.fixture +def valid_review_data(min_len): + """Generates a valid payload for review creation/update tests.""" + return { + "term": "23F", + "professor": "Dr. Testing", + "comments": "a" * min_len, + } + + +# ------------------------------------------------------------------------- +# 4. URL Fixtures (Routing) +# ------------------------------------------------------------------------- + + +@pytest.fixture +def course_reviews_url(course): + """URL for listing/posting reviews for a specific course.""" + return reverse("course_review_api", kwargs={"course_id": course.id}) + + +@pytest.fixture +def personal_reviews_list_url(): + """URL for the current user's personal review list.""" + return reverse("user_reviews_api") + + +@pytest.fixture +def personal_review_detail_url(review): + """URL for GET/PUT/DELETE a specific review owned by the user.""" + return reverse("user_review_api", kwargs={"review_id": review.id}) + + +@pytest.fixture +def other_review_detail_url(other_review): + """URL for a review NOT owned by the current user (used for 404/Security).""" + return reverse("user_review_api", kwargs={"review_id": other_review.id}) diff --git a/apps/web/tests/factories.py b/apps/web/tests/factories.py index f501948..53c32ee 100644 --- a/apps/web/tests/factories.py +++ b/apps/web/tests/factories.py @@ -1,88 +1,79 @@ import factory +import factory.fuzzy from django.contrib.auth.models import User -from apps.web import models -from lib import constants +# Import models from their individual files +from apps.web.models.course import Course +from apps.web.models.review import Review +from apps.web.models.student import Student +from apps.web.models.course_offering import CourseOffering class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User - username = factory.Faker("first_name") - email = factory.Faker("email") - first_name = factory.Faker("first_name") - last_name = factory.Faker("last_name") - is_active = True + username = factory.Sequence(lambda n: f"user_{n}") + email = factory.LazyAttribute(lambda o: f"{o.username}@example.com") @classmethod - def _prepare(cls, create, **kwargs): - # thanks: https://gist.github.com/mbrochh/2433411 - password = factory.Faker("password") - if "password" in kwargs: - password = kwargs.pop("password") - user = super(UserFactory, cls)._prepare(create, **kwargs) - user.set_password(password) - if create: - user.save() - return user + def _create(cls, model_class, *args, **kwargs): + """Ensure password is hashed correctly so auth_client can log in""" + password = kwargs.pop("password", "password123") + obj = model_class(*args, **kwargs) + obj.set_password(password) + obj.save() + return obj class CourseFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Course + model = Course - title = factory.Faker("words") - department = "COSC" - number = factory.Faker("random_number") - url = factory.Faker("url") - description = factory.Faker("text") + course_title = factory.Faker("sentence", nb_words=3) + department = factory.fuzzy.FuzzyChoice(["MATH", "PHYS", "EECS"]) + number = factory.Sequence(lambda n: 100 + n) + + @factory.lazy_attribute + def course_code(self): + """Generates unique MATH100, PHYS101, etc.""" + return f"{self.department}{str(self.number):<04}J" + + description = factory.Faker("paragraph") class CourseOfferingFactory(factory.django.DjangoModelFactory): class Meta: - model = models.CourseOffering + model = CourseOffering course = factory.SubFactory(CourseFactory) - - term = constants.CURRENT_TERM - section = factory.Faker("random_number") + term = "23F" + section = factory.Sequence(lambda n: n) period = "2A" class ReviewFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Review + model = Review course = factory.SubFactory(CourseFactory) user = factory.SubFactory(UserFactory) + term = "23F" professor = factory.Faker("name") - term = constants.CURRENT_TERM comments = factory.Faker("paragraph") -class DistributiveRequirementFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.DistributiveRequirement - - name = "ART" - distributive_type = models.DistributiveRequirement.DISTRIBUTIVE - - class StudentFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Student + model = Student user = factory.SubFactory(UserFactory) - confirmation_link = User.objects.make_random_password(length=16) -class VoteFactory(factory.django.DjangoModelFactory): +class DistributiveRequirementFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Vote + # Using string reference for potential distributive requirements model + model = "web.DistributiveRequirement" - value = 0 - course = factory.SubFactory(CourseFactory) - user = factory.SubFactory(UserFactory) - category = models.Vote.CATEGORIES.QUALITY + name = factory.Sequence(lambda n: f"Dist{n}") diff --git a/apps/web/tests/lib_tests/test_terms.py b/apps/web/tests/lib_tests/test_terms.py index 662deee..baffc6f 100644 --- a/apps/web/tests/lib_tests/test_terms.py +++ b/apps/web/tests/lib_tests/test_terms.py @@ -34,6 +34,12 @@ def test_term_regex_allows_for_lower_and_upper_terms(self): and term_data.group("year") == "16" and term_data.group("term") == "w" ) + term_data = terms.term_regex.match("16F") + self.assertTrue( + term_data + and term_data.group("year") == "16" + and term_data.group("term") == "F" + ) def test_term_regex_allows_for_current_term(self): term_data = terms.term_regex.match(constants.CURRENT_TERM) @@ -52,7 +58,7 @@ def test_numeric_value_of_term_returns_0_if_bad_term(self): self.assertEqual(terms.numeric_value_of_term("fall"), 0) def test_numeric_value_of_term_ranks_terms_in_correct_order(self): - correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15W", "16S", "20x"] + correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15w", "16S", "20x"] shuffled_data = list(correct_order) while correct_order == shuffled_data: random.shuffle(shuffled_data) @@ -66,9 +72,10 @@ def test_numeric_value_of_term_gives_expected_numeric_value(self): self.assertEqual(terms.numeric_value_of_term("16W"), 161) def test_is_valid_term_returns_false_if_in_future(self): - next_year = ( - int(terms.term_regex.match(constants.CURRENT_TERM).group("year")) + 1 - ) + term_data = terms.term_regex.match(constants.CURRENT_TERM) + if term_data is None: + raise AssertionError("CURRENT_TERM did not match term_regex") + next_year = int(term_data.group("year")) + 1 self.assertFalse(terms.is_valid_term("{}f".format(next_year))) def test_is_valid_term_returns_false_if_no_term(self): diff --git a/apps/web/tests/model_tests/__init__.py b/apps/web/tests/model_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/tests/model_tests/test_course.py b/apps/web/tests/model_tests/test_course.py deleted file mode 100644 index 9cba58b..0000000 --- a/apps/web/tests/model_tests/test_course.py +++ /dev/null @@ -1,161 +0,0 @@ -from django.test import TestCase - -from apps.web.models import Course, CourseOffering -from apps.web.tests import factories - - -class CourseTestCase(TestCase): - TEST_TERM = "16W" - - def setUp(self): - self.distrib = factories.DistributiveRequirementFactory() - self.c1 = factories.CourseFactory() - self.c2 = factories.CourseFactory() - self.c1o = factories.CourseOfferingFactory(term=self.TEST_TERM, course=self.c1) - self.c2o = factories.CourseOfferingFactory(term=self.TEST_TERM, course=self.c2) - - def test_for_term_retrieves_courses_for_term(self): - self.assertEqual(len(Course.objects.for_term(self.TEST_TERM)), 2) - CourseOffering.objects.all().delete() - self.assertEqual(len(Course.objects.for_term(self.TEST_TERM)), 0) - - def test_for_term_filters_by_distrib_correctly(self): - self.assertEqual( - len(Course.objects.for_term(self.TEST_TERM, self.distrib.name)), 0 - ) - self.c1.distribs.add(self.distrib) - self.c1.save() - self.assertEqual( - len(Course.objects.for_term(self.TEST_TERM, self.distrib.name)), 1 - ) - - def test_review_search_retrieves_review_by_comments(self): - c1r = factories.ReviewFactory( - course=self.c1, comments="this class was not very good" - ) - self.assertEqual(self.c1.search_reviews("CLASS")[0], c1r) - self.assertEqual(len(self.c1.search_reviews("asdf")), 0) - - def test_review_search_retrieves_review_by_professor(self): - c1r = factories.ReviewFactory(course=self.c1, professor="Layup List") - self.assertEqual(self.c1.search_reviews("layup")[0], c1r) - self.assertEqual(len(self.c1.search_reviews("easy")), 0) - - def test_review_search_retrieves_review_by_both_comments_and_professor(self): - factories.ReviewFactory(course=self.c1, comments="this class is a layup") - factories.ReviewFactory(course=self.c1, professor="Layup List") - self.assertEqual(len(self.c1.search_reviews("lay")), 2) - - def test_is_offered_returns_true_if_offered_on_specified_term(self): - self.assertTrue(self.c1.is_offered(self.TEST_TERM)) - self.c1o.term = "00X" - self.c1o.save() - self.assertFalse(self.c1.is_offered(self.TEST_TERM)) - - def test_offered_times_retrieves_times_offered_for_term(self): - offered_times = ("2A", "3B") - self.c1o.period, self.c2o.period = offered_times - self.c2o.course = self.c1 - self.c1o.save() - self.c2o.save() - times = self.c1.offered_times(self.TEST_TERM) - self.assertEqual(len(times), len(offered_times)) - for time in times: - self.assertTrue(time in offered_times) - - def test_offered_times_ignores_duplicates(self): - offered_time = "2A" - self.c1o.period, self.c2o.period = offered_time, offered_time - self.c2o.course = self.c1 - self.c1o.save() - self.c2o.save() - times = self.c1.offered_times(self.TEST_TERM) - self.assertEqual(len(times), 1) - self.assertEqual(times[0], offered_time) - - def test_offered_times_redacts_unordinary_times(self): - offered_times = ("2A", "F 4:00 PM-6:00 PM") - self.c1o.period, self.c2o.period = offered_times - self.c2o.course = self.c1 - self.c1o.save() - self.c2o.save() - times = self.c1.offered_times(self.TEST_TERM) - self.assertEqual(len(times), len(offered_times)) - for time in times: - self.assertTrue(time in offered_times or time == "other") - self.assertTrue("other" in times) - - -class CourseSearchTestCase(TestCase): - DEPARTMENT_4 = "COSC" - DEPARTMENT_3 = "REL" - - def setUp(self): - self.c1 = factories.CourseFactory(department=self.DEPARTMENT_4, number=1) - self.c2 = factories.CourseFactory(department=self.DEPARTMENT_4, number=2) - self.c3 = factories.CourseFactory(department=self.DEPARTMENT_3, number=3) - self.c4 = factories.CourseFactory(department=self.DEPARTMENT_3, number=4) - - def test_search_returns_nothing_if_no_query(self): - self.assertEqual(len(Course.objects.search("")), 0) - - def test_searches_by_four_letter_department(self): - # e.g. HIST - self.c4.title = "something something {}".format(self.DEPARTMENT_4) - self.c4.save() - self.assertEqual(len(Course.objects.search(self.DEPARTMENT_4)), 2) - - def test_searches_by_three_letter_department(self): - # e.g. REL - self.c2.title = "something something {}".format(self.DEPARTMENT_3) - self.c2.save() - self.assertEqual(len(Course.objects.search(self.DEPARTMENT_3)), 2) - - def test_searches_as_query_if_dpt_length_but_not_valid_department(self): - # e.g. war, boom - self.c1.title = "the art of war" - self.c2.title = "World War II" - self.c1.save() - self.c2.save() - self.assertEqual(len(Course.objects.search("war")), 2) - - self.c1.title = "the art of boomerangs" - self.c2.title = "World BOOM II" - self.c1.save() - self.c2.save() - self.assertEqual(len(Course.objects.search("war")), 0) - self.assertEqual(len(Course.objects.search("boom")), 2) - - def test_searches_by_number_and_subnumber(self): - # e.g. COSC 089.01, COSC089.01 - self.assertEqual( - len(Course.objects.search("{}1.5".format(self.DEPARTMENT_4))), 0 - ) - self.assertEqual( - len(Course.objects.search("{} 1.5".format(self.DEPARTMENT_4))), 0 - ) - self.c1.subnumber = 5 - self.c1.save() - self.assertEqual( - len(Course.objects.search("{}1.5".format(self.DEPARTMENT_4))), 1 - ) - self.assertEqual( - len(Course.objects.search("{} 1.5".format(self.DEPARTMENT_4))), 1 - ) - - def test_searches_by_number_and_no_subnumber(self): - # e.g. COSC 1 - self.assertEqual(len(Course.objects.search("{}1".format(self.DEPARTMENT_4))), 1) - self.assertEqual( - len(Course.objects.search("{} 1".format(self.DEPARTMENT_4))), 1 - ) - self.c1.delete() - self.assertEqual(len(Course.objects.search("{}1".format(self.DEPARTMENT_4))), 0) - self.assertEqual( - len(Course.objects.search("{} 1".format(self.DEPARTMENT_4))), 0 - ) - - def test_search_is_case_insensitive(self): - self.c1.title = "The Art of War" - self.c1.save() - self.assertEqual(len(Course.objects.search("art of war")), 1) diff --git a/apps/web/tests/model_tests/test_student.py b/apps/web/tests/model_tests/test_student.py deleted file mode 100644 index 0cf902f..0000000 --- a/apps/web/tests/model_tests/test_student.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.test import TestCase - -from apps.web.models import Vote -from apps.web.tests import factories -from lib import constants - - -class StudentTestCase(TestCase): - def test_can_see_recommendations(self): - s = factories.StudentFactory() - self.assertFalse(s.can_see_recommendations()) - - # create sufficient votes of wrong type - for _ in range(constants.REC_UPVOTE_REQ): - factories.VoteFactory( - user=s.user, category=Vote.CATEGORIES.DIFFICULTY, value=1 - ) - for value in [-1, 0]: - for category in [c[0] for c in Vote.CATEGORIES.CHOICES]: - factories.VoteFactory(user=s.user, category=category, value=value) - - # cannot view if does not reach vote count - Vote.objects.all().delete() - factories.ReviewFactory(user=s.user) - for _ in range(constants.REC_UPVOTE_REQ - 1): - factories.VoteFactory( - user=s.user, category=Vote.CATEGORIES.QUALITY, value=1 - ) - self.assertFalse(s.can_see_recommendations()) - - # can view - factories.VoteFactory(user=s.user, category=Vote.CATEGORIES.QUALITY, value=1) - self.assertTrue(s.can_see_recommendations()) diff --git a/apps/web/tests/model_tests/test_vote.py b/apps/web/tests/model_tests/test_vote.py deleted file mode 100644 index da65912..0000000 --- a/apps/web/tests/model_tests/test_vote.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.test import TestCase - -from apps.web.models import Course, Vote -from apps.web.tests import factories - - -class VoteTestCase(TestCase): - def test_vote(self): - gv = factories.VoteFactory(category=Vote.CATEGORIES.QUALITY) - lv = factories.VoteFactory( - course=gv.course, user=gv.user, category=Vote.CATEGORIES.DIFFICULTY - ) - c = gv.course - u = gv.user - - # doesn't work if value > 5 - self.assertEqual( - ( - None, - False, - ), - Vote.objects.vote(6, c.id, Vote.CATEGORIES.DIFFICULTY, u), - ) - - self.assertEqual(gv.value, 0) - self.assertEqual(lv.value, 0) - self.assertEqual(c.difficulty_score, 0) - self.assertEqual(c.quality_score, 0) - - # can rate 1-5 - self.assertEqual( - ( - 5.0, - False, - ), - Vote.objects.vote(5, c.id, Vote.CATEGORIES.QUALITY, u.id), - ) - self.assertEqual( - ( - 2.0, - False, - ), - Vote.objects.vote(2, c.id, Vote.CATEGORIES.DIFFICULTY, u.id), - ) - - gv.refresh_from_db() - lv.refresh_from_db() - c.refresh_from_db() - self.assertEqual(gv.value, 5) - self.assertEqual(lv.value, 2) - self.assertEqual(c.quality_score, 5.0) - self.assertEqual(c.difficulty_score, 2.0) - - # can unvote - self.assertEqual( - ( - 0.0, - True, - ), - Vote.objects.vote(5, c.id, Vote.CATEGORIES.QUALITY, u.id), - ) - self.assertEqual( - ( - 0.0, - True, - ), - Vote.objects.vote(2, c.id, Vote.CATEGORIES.DIFFICULTY, u.id), - ) - - gv.refresh_from_db() - lv.refresh_from_db() - c.refresh_from_db() - self.assertEqual(gv.value, 0) - self.assertEqual(lv.value, 0) - self.assertEqual(c.quality_score, 0.0) - self.assertEqual(c.difficulty_score, 0.0) - - def test_group_courses_with_votes(self): - factories.CourseFactory() - factories.CourseFactory() - v = factories.VoteFactory() - factories.CourseFactory() - - results = Vote.objects.group_courses_with_votes( - Course.objects.all(), - v.category, - v.user, - ) - - self.assertEqual(4, len(results)) - for course, vote in results: - if course == v.course: - self.assertEqual(vote, v) - else: - self.assertEqual(vote, None) diff --git a/apps/web/tests/test_auth.py b/apps/web/tests/test_auth.py new file mode 100644 index 0000000..ffef89c --- /dev/null +++ b/apps/web/tests/test_auth.py @@ -0,0 +1,56 @@ +import pytest +from django.urls import reverse + +from apps.web.tests.factories import ReviewFactory + + +@pytest.mark.django_db +class TestUserStatusAPI: + def test_user_status_anonymous(self, base_client): + """Test that unauthenticated users get isAuthenticated=False""" + url = reverse("user_status") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["isAuthenticated"] is False + assert "username" not in response.data + + def test_user_status_authenticated(self, auth_client, user): + """Test that authenticated users get isAuthenticated=True and their username""" + url = reverse("user_status") + # auth_client is already logged in via the fixture in conftest.py + response = auth_client.get(url) + + assert response.status_code == 200 + assert response.data["isAuthenticated"] is True + assert response.data["username"] == user.username + + +@pytest.mark.django_db +class TestLandingPageAPI: + def test_landing_page_review_count(self, base_client, review): + """Verify landing page shows correct review statistics.""" + url = reverse("landing_api") + response = base_client.get(url) + assert response.status_code == 200 + # Should be at least 1 due to the 'review' fixture + assert response.data["review_count"] == 1 + + def test_landing_page_review_count_empty(self, base_client, db): + """Verify review count is 0 when no reviews exist in the database.""" + url = reverse("landing_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["review_count"] == 0 + + def test_landing_page_review_count_multiple(self, base_client, db): + """Verify review count returns the correct total when multiple reviews exist.""" + # Create 5 reviews across different courses/users + ReviewFactory.create_batch(5) + + url = reverse("landing_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["review_count"] == 5 diff --git a/apps/web/tests/test_course.py b/apps/web/tests/test_course.py new file mode 100644 index 0000000..dfccafc --- /dev/null +++ b/apps/web/tests/test_course.py @@ -0,0 +1,307 @@ +import pytest +from django.urls import reverse + +from apps.web.models import Review, Vote + + +@pytest.mark.django_db +class TestCourseAPIUnauthenticated: + def test_list_courses_anonymous(self, base_client, course_factory): + """Verify that any user can list courses with pagination.""" + # Create 3 courses using the factory + course_factory.create_batch(3) + + url = reverse("courses_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["count"] == 3 + assert "results" in response.data + + def test_filter_courses_by_department(self, base_client, course_factory): + """Verify filtering courses by department code.""" + # Create specific courses + course_factory(department="MATH", course_code="MATH101J") + course_factory(department="PHYS", course_code="PHYS101J") + + url = reverse("courses_api") + # Test filtering for MATH department + response = base_client.get(url, {"department": "MATH"}) + + assert response.status_code == 200 + # Check that filtering worked (only 1 course returned) + assert response.data["count"] == 1 + + # Check course_code instead of department key + # Since 'department' is not in the response, we verify 'MATH101J' + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_filter_courses_by_code(self, base_client, course_factory): + course_factory(course_code="PHYS101J") + course_factory(course_code="MATH102J") + course_factory(course_code="MATH101J") + + url = reverse("courses_api") + + response = base_client.get(url, {"code": "MATH"}) + assert response.status_code == 200 + assert response.data["count"] == 2 + + response = base_client.get(url, {"code": "101"}) + assert response.data["count"] == 2 + + def test_sort_courses_by_review_count_anonymous( + self, base_client, user, course_factory + ): + c1 = course_factory(course_code="MATH101J") + c2 = course_factory(course_code="MATH102J") + Review.objects.create( + course=c1, user=user, term="23S", professor="Prof X", comments="Great!" + ) + + url = reverse("courses_api") + + response = base_client.get( + url, {"sort_by": "review_count", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == c1.course_code + assert response.data["results"][1]["course_code"] == c2.course_code + + def test_sort_courses_by_score_anonymous(self, base_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = base_client.get( + url, {"sort_by": "quality_score", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == "MATH102J" + assert response.data["results"][1]["course_code"] == "MATH101J" + + def test_filter_courses_by_score_anonymous(self, base_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = base_client.get(url, {"min_quality": 4}) + assert response.status_code == 200 + assert response.data["count"] == 2 + + def test_filter_courses_by_difficulty_anonymous( + self, base_client, user, course_factory + ): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY + ) + + url = reverse("courses_api") + + response = base_client.get(url, {"min_difficulty": 4}) + assert response.status_code == 200 + assert response.data["count"] == 2 + + def test_course_detail_retrieval(self, base_client, course): + """Verify retrieving details for a specific course using its ID.""" + url = reverse("course_detail_api", kwargs={"course_id": course.id}) + response = base_client.get(url) + + assert response.status_code == 200 + # Verify the title matches the fixture-created course + assert response.data["course_title"] == course.course_title + + def test_course_detail_fields_unauthenticated(self, base_client, course): + url = reverse("course_detail_api", kwargs={"course_id": course.id}) + response = base_client.get(url) + assert response.status_code == 200 + + hidden_fields = [ + "quality_score", + "difficulty_score", + "difficulty_vote", + "quality_vote", + "quality_vote_count", + "difficulty_vote_count", + ] + for field in hidden_fields: + assert field not in response.data + + def test_department_listings(self, base_client, course_factory): + """Verify the endpoint that lists all departments and their course counts.""" + course_factory(department="MATH") + course_factory(department="MATH") + course_factory(department="EECS") + + url = reverse("departments_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert isinstance(response.data, list) + + # Find MATH department in the list + math_dept = next(item for item in response.data if item["code"] == "MATH") + assert math_dept["count"] == 2 + + def test_department_api_empty(self, base_client, db): + url = reverse("departments_api") + response = base_client.get(url) + assert response.status_code == 200 + assert response.data == [] + + def test_department_api_sorting(self, base_client, course_factory): + course_factory(department="ENGL", course_code="ENGL1000J") + course_factory(department="MATH", course_code="MATH1560J") + response = base_client.get(reverse("departments_api")) + assert response.data[0]["code"] == "ENGL" + + +@pytest.mark.django_db +class TestCourseAPIAuthenticated: + def test_sort_by_review_count(self, auth_client, user, course_factory): + c_hot = course_factory(course_code="ENGR101J") + course_factory(course_code="ENGR100J") + Review.objects.create( + course=c_hot, user=user, term="23S", professor="Prof X", comments="Great!" + ) + url = reverse("courses_api") + + response = auth_client.get( + url, {"sort_by": "review_count", "sort_order": "desc"} + ) + + results = response.data["results"] + assert results[0]["course_code"] == "ENGR101J" + assert results[1]["course_code"] == "ENGR100J" + + def test_filter_courses_by_quality(self, auth_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = auth_client.get(url, {"min_quality": 4}) + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_filter_courses_by_difficulty(self, auth_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY + ) + + url = reverse("courses_api") + + response = auth_client.get(url, {"min_difficulty": 4}) + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_sort_courses_by_quality_score(self, auth_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = auth_client.get( + url, {"sort_by": "quality_score", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_sort_courses_by_difficulty_score(self, auth_client, user, course_factory): + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY + ) + + url = reverse("courses_api") + + response = auth_client.get( + url, {"sort_by": "difficulty_score", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_sort_order_asc_and_desc(self, auth_client, course_factory): + course_factory(course_code="MATH101J") + course_factory(course_code="PHY101J") + + url = reverse("courses_api") + + # case 1: Ascending + res_asc = auth_client.get(url, {"sort_by": "course_code", "sort_order": "asc"}) + assert res_asc.data["results"][0]["course_code"] == "MATH101J" + assert res_asc.data["results"][1]["course_code"] == "PHY101J" + + # case 2: Descending + res_desc = auth_client.get( + url, {"sort_by": "course_code", "sort_order": "desc"} + ) + assert res_desc.data["results"][0]["course_code"] == "PHY101J" + assert res_desc.data["results"][1]["course_code"] == "MATH101J" + + def test_course_detail_fields_authenticated(self, auth_client, course): + url = reverse("course_detail_api", kwargs={"course_id": course.id}) + response = auth_client.get(url) + assert response.status_code == 200 + + required_fields = [ + "quality_score", + "difficulty_score", + "difficulty_vote", + "quality_vote", + "quality_vote_count", + "difficulty_vote_count", + ] + for field in required_fields: + assert field in response.data diff --git a/apps/web/tests/test_review.py b/apps/web/tests/test_review.py new file mode 100644 index 0000000..d9f7cb8 --- /dev/null +++ b/apps/web/tests/test_review.py @@ -0,0 +1,164 @@ +import pytest +from django.urls import reverse +from rest_framework import status + +from apps.web.models import Review +from apps.web.tests.factories import ReviewFactory + + +@pytest.mark.django_db +class TestReviewAPIUnauthenticated: + def test_get_course_reviews_anonymous(self, base_client, course_reviews_url): + """1. Verify anonymous users cannot list course reviews.""" + response = base_client.get(course_reviews_url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + def test_get_personal_reviews_anonymous( + self, base_client, personal_reviews_list_url + ): + """2. Verify anonymous users cannot access personal review list.""" + response = base_client.get(personal_reviews_list_url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + def test_delete_review_anonymous_forbidden(self, base_client, review): + """3. Verify that unauthenticated users are forbidden from deleting reviews.""" + url = reverse("user_review_api", kwargs={"review_id": review.id}) + response = base_client.delete(url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + assert Review.objects.filter(id=review.id).exists() + + def test_review_detail_anonymous_forbidden( + self, base_client, personal_review_detail_url + ): + response = base_client.get(personal_review_detail_url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + +@pytest.mark.django_db +class TestReviewAPIAuthenticated: + def test_create_review_success( + self, auth_client, course_reviews_url, course, valid_review_data + ): + """4. Verify successful review creation with valid data.""" + response = auth_client.post( + course_reviews_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert Review.objects.filter(course=course).count() == 1 + + def test_list_personal_reviews( + self, auth_client, personal_reviews_list_url, review, other_review + ): + """5. Verify user can list their own reviews.""" + response = auth_client.get(personal_reviews_list_url) + assert response.status_code == status.HTTP_200_OK + assert any(r["id"] == review.id for r in response.data) + assert all(r["id"] != other_review.id for r in response.data) + + def test_retrieve_review_detail( + self, auth_client, personal_review_detail_url, review + ): + """6. Verify user can retrieve their own review details.""" + response = auth_client.get(personal_review_detail_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == review.id + + def test_filter_reviews_by_author_me(self, auth_client, course_reviews_url, review): + """7. Verify 'author=me' filters reviews for a specific course.""" + ReviewFactory(course=review.course) + response = auth_client.get(course_reviews_url, {"author": "me"}) + assert response.status_code == status.HTTP_200_OK + assert [r["id"] for r in response.data] == [review.id] + + def test_search_reviews_by_professor( + self, auth_client, course_reviews_url, course, min_len + ): + """8. Verify search 'q' works for professor names.""" + ReviewFactory(course=course, professor="UniqueProf", comments="c" * min_len) + ReviewFactory(course=course, professor="OtherProf", comments="c" * min_len) + response = auth_client.get(course_reviews_url, {"q": "UniqueProf"}) + assert all(r["professor"] == "UniqueProf" for r in response.data) + + def test_update_review_success( + self, auth_client, personal_review_detail_url, review, valid_review_data + ): + """9. Verify successful update of user's own review.""" + valid_review_data["comments"] = "b" * len(valid_review_data["comments"]) + response = auth_client.put( + personal_review_detail_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_200_OK + review.refresh_from_db() + assert review.comments == valid_review_data["comments"] + + def test_delete_review_success( + self, auth_client, personal_review_detail_url, review + ): + """10. Verify successful deletion of user's own review.""" + response = auth_client.delete(personal_review_detail_url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Review.objects.filter(id=review.id).exists() + + def test_create_validation_length_error( + self, auth_client, course_reviews_url, valid_review_data, min_len + ): + """13. Verify rejection of comments shorter than min_length.""" + valid_review_data["comments"] = "a" * (min_len - 1) + response = auth_client.post( + course_reviews_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_validation_missing_field( + self, auth_client, personal_review_detail_url, min_len + ): + """14. Verify PUT fails if required fields (professor) are missing.""" + response = auth_client.put( + personal_review_detail_url, + {"comments": "b" * min_len}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_duplicate_review_denied(self, auth_client, review, valid_review_data): + """15. Verify user cannot review the same course twice (403).""" + url = reverse("course_review_api", kwargs={"course_id": review.course.id}) + response = auth_client.post(url, valid_review_data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_access_other_user_review_404(self, auth_client, other_review_detail_url): + """16. Security: Verify user cannot access someone else's review ID.""" + response = auth_client.get(other_review_detail_url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_non_existent_id(self, auth_client): + """17. Verify deletion of non-existent review ID returns 404.""" + url = reverse("user_review_api", kwargs={"review_id": 99999}) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_post_to_invalid_course_id(self, auth_client, valid_review_data): + """18. Verify posting to non-existent course ID returns 404.""" + url = reverse("course_review_api", kwargs={"course_id": 88888}) + response = auth_client.post(url, valid_review_data, format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_review_response_contains_votes( + self, auth_client, personal_review_detail_url + ): + """19. Verify vote statistics are included in the response.""" + response = auth_client.get(personal_review_detail_url) + assert "kudos_count" in response.data + assert "dislike_count" in response.data diff --git a/apps/web/tests/test_vote.py b/apps/web/tests/test_vote.py new file mode 100644 index 0000000..f77b190 --- /dev/null +++ b/apps/web/tests/test_vote.py @@ -0,0 +1,105 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +@pytest.mark.django_db +class TestCourseVoteAPIAuthenticated: + def test_course_vote_quality_success(self, auth_client, course): + """Test authenticated user voting for course quality.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + data = {"value": 5, "forLayup": False} + response = auth_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert "new_score" in response.data + assert response.data["new_vote_count"] == 1 + assert response.data["was_unvote"] is False + + def test_course_vote_change_value(self, auth_client, course): + """Verify user can change their vote value (e.g., from 5 to 3).""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + + # Initial vote + auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + # Change vote + response = auth_client.post(url, {"value": 3, "forLayup": False}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["new_score"] == 3.0 + assert response.data["new_vote_count"] == 1 # Count stays same + + def test_course_vote_cancel(self, auth_client, course): + """Verify voting the same value twice cancels (unvotes) the vote.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + + auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + # Vote same value again to toggle off + response = auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["was_unvote"] is True + assert response.data["new_vote_count"] == 0 + + def test_course_vote_invalid_range_400(self, auth_client, course): + """Verify 400 error for scores outside 1-5.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + response = auth_client.post( + url, {"value": 10, "forLayup": False}, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +class TestCourseVoteAPIUnauthenticated: + def test_course_vote_anonymous_denied(self, base_client, course): + """Verify unauthenticated users cannot vote.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + response = base_client.post(url, {"value": 5, "forLayup": False}, format="json") + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + +@pytest.mark.django_db +class TestReviewVoteAPIAuthenticated: + def test_review_vote_kudos_success(self, auth_client, review): + """Test authenticated user giving kudos to a review.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + data = {"is_kudos": True} + response = auth_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["kudos_count"] == 1 + assert response.data["user_vote"] is True + + def test_review_vote_toggle_off(self, auth_client, review): + """Verify that clicking kudos twice cancels the vote.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + + auth_client.post(url, {"is_kudos": True}, format="json") + # Second click + response = auth_client.post(url, {"is_kudos": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["kudos_count"] == 0 + assert response.data["user_vote"] is None + + def test_review_vote_not_found_404(self, auth_client): + """Verify 404 for non-existent review ID.""" + url = reverse("review_vote_api", kwargs={"review_id": 99999}) + response = auth_client.post(url, {"is_kudos": True}, format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +class TestReviewVoteAPIUnauthenticated: + def test_review_vote_anonymous_denied(self, base_client, review): + """Verify unauthenticated users cannot vote on reviews.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + response = base_client.post(url, {"is_kudos": True}, format="json") + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] diff --git a/apps/web/views.py b/apps/web/views.py index 47e177e..8e55d9e 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -134,7 +134,7 @@ def _filter_by_score(self, queryset): try: threshold = int(param_value) queryset = queryset.filter(**{f"{field_name}__gte": threshold}) - except (ValueError, TypeError): + except ValueError, TypeError: pass return queryset diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..440bc30 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,34 @@ +services: + db: + ports: + - "5432:5432" + + cache: + ports: + - "6379:6379" + + backend: + build: + context: . + dockerfile: Containerfile + ports: + - "8000:8000" + env_file: + - .env + environment: + DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview} + REDIS__URL: ${REDIS__URL:-redis://cache:6379/0} + volumes: + - ./config.yaml:/app/config.yaml:ro + + migrate: + build: + context: . + dockerfile: Containerfile + env_file: + - .env + environment: + DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview} + REDIS__URL: ${REDIS__URL:-redis://cache:6379/0} + volumes: + - ./config.yaml:/app/config.yaml:ro diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..ba0c787 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,20 @@ +services: + backend: + ports: + - "8000:8000" + env_file: + - /etc/coursereview/secrets.env + environment: + DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview} + REDIS__URL: ${REDIS__URL:-redis://cache:6379/0} + volumes: + - /etc/coursereview/config.yaml:/app/config.yaml:ro + + migrate: + env_file: + - /etc/coursereview/secrets.env + environment: + DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview} + REDIS__URL: ${REDIS__URL:-redis://cache:6379/0} + volumes: + - /etc/coursereview/config.yaml:/app/config.yaml:ro diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a20016d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,61 @@ +services: + db: + image: postgres:18-alpine + volumes: + - postgres18_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${POSTGRES_DB:-coursereview} + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-test} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-coursereview}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + cache: + image: valkey/valkey:9-alpine + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + image: coursereview-backend + depends_on: + db: + condition: service_healthy + cache: + condition: service_healthy + environment: + PYTHONUNBUFFERED: "1" + GUNICORN_CMD_ARGS: "--control-socket /tmp/gunicorn.ctl --worker-tmp-dir /tmp" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "__import__('urllib.request').request.urlopen('http://127.0.0.1:8000/api/user/status/',timeout=5)", + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + migrate: + image: coursereview-backend + depends_on: + db: + condition: service_healthy + environment: + PYTHONUNBUFFERED: "1" + command: ["python", "django_manage.py", "migrate"] + restart: "no" + +volumes: + postgres18_data: diff --git a/manage.py b/django_manage.py similarity index 100% rename from manage.py rename to django_manage.py diff --git a/docs/config.md b/docs/config.md index b0b73d3..e68f003 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,38 +1,101 @@ # Config -Use YAML and environment variables for robust and secure configuration. All the customizable fields can be specified in `config.yaml` and environment variables (or `.env` file at local dev). +CourseReview uses three configuration sources, merged in this order: -## TL;DR +```text +environment variables > config.yaml > built-in defaults +``` + +Use: + +- environment variables for secrets and credentials +- `config.yaml` for non-secret, environment-specific settings +- built-in defaults for sane local defaults + +For local development, copy: + +- `.env.example` to `.env` +- `config.yaml.example` to `config.yaml` + +Neither file should be committed. + +## Quick start + +1. Copy `.env.example` to `.env` +2. Copy `config.yaml.example` to `config.yaml` +3. Fill in required values +4. Run the app with `python run.py dev` + +## Required values + +### Usually set in `.env` + +These are typically secrets: + +- `SECRET_KEY` +- `TURNSTILE_SECRET_KEY` +- `QUEST__SIGNUP__API_KEY` +- `QUEST__LOGIN__API_KEY` +- `QUEST__RESET_PASSWORD__API_KEY` + +Infrastructure URLs are also commonly set in `.env`: + +- `DATABASE__URL` +- `REDIS__URL` + +### Usually set in `config.yaml` + +These are typically non-secret and environment-specific: + +- `DEBUG` +- `ALLOWED_HOSTS` +- `CORS_ALLOWED_ORIGINS` +- `QUEST.SIGNUP.URL` +- `QUEST.SIGNUP.QUESTIONID` +- `QUEST.LOGIN.URL` +- `QUEST.LOGIN.QUESTIONID` +- `QUEST.RESET_PASSWORD.URL` +- `QUEST.RESET_PASSWORD.QUESTIONID` + +## Environment variables -1. Copy `.env.example` file to `.env`, fill in: - - `SECRET_KEY` - - `TURNSTILE_SECRET_KEY` - - `QUEST__SIGNUP__API_KEY` - - `QUEST__LOGIN__API_KEY` - - `QUEST__RESET__API_KEY` - - (If in production) `DATABASE__URL` and `REDIS__URL` -2. Copy `config.yaml.example` to `config.yaml`, fill in: - - `DEBUG`: `true` if at dev, `false` if in production - - `URL` and `QUESTIONID` in all actions in `QUEST` - - (If in production) backend domains in `ALLOWED_HOSTS`, frontend domains in `CORS_ALLOWED_ORIGINS` -3. That's it! +Environment variables use `__` to represent nesting. -## Priority +Examples: -env > `config.yaml` > default config +```env +DATABASE__URL=postgres://admin:test@db:5432/coursereview +REDIS__URL=redis://cache:6379/0 +AUTH__OTP_TIMEOUT=60 +WEB__COURSE__PAGE_SIZE=5 +QUEST__RESET_PASSWORD__QUESTIONID=10000002 +``` + +Lists can be overridden with comma-separated strings: + +```env +ALLOWED_HOSTS=localhost,127.0.0.1,api.example.com +``` + +### Local development note + +Keep the URLs in `.env` container-friendly: + +```env +DATABASE__URL=postgres://admin:test@db:5432/coursereview +REDIS__URL=redis://cache:6379/0 +``` + +This is intentional. -Every field (including nested ones) can be specified anywhere (i.e. env, `config.yaml`, none/default), and config will be loaded with each field following the above priority order. +- Podman Compose needs `db` and `cache` +- host-side commands such as `python run.py dev`, `python run.py django ...`, and `python run.py test` automatically rewrite them to `127.0.0.1` -### Environment Variables +So do **not** change `.env` to `localhost` just for host-side development. -- Environment variables are used to set secrets and credentials. -- Use `.env` file for local development. Directly export environment variables at production. -- Copy this `.env.example` file to `.env` and fill in the secrets for local development. -- `.env` should **NOT** be committed (already git ignored). -- Use `PARENT__CHILD` format to override nested settings. `__` means parental relationship. -- Use `,` as delimiter for lists. +## `.env.example` -```env path=.env +```env # .env.example # Copy this file to .env and fill in the secrets for local development. # DO NOT COMMIT .env TO VERSION CONTROL. @@ -49,8 +112,8 @@ SECRET_KEY=django-insecure-my-local-dev-secret-key # --- Infrastructure (REQUIRED) --- # Use a single URL for database and Redis connections. # Format: driver://user:password@host:port/dbname -DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview -REDIS__URL=redis://localhost:6379/0 +DATABASE__URL=postgres://admin:test@db:5432/coursereview +REDIS__URL=redis://cache:6379/0 # --- External Services Secrets (REQUIRED) --- TURNSTILE_SECRET_KEY=dummy0 @@ -65,25 +128,25 @@ QUEST__LOGIN__API_KEY=dummy2 # QUEST__LOGIN__URL= # QUEST__LOGIN__QUESTIONID= -QUEST__RESET__API_KEY=dummy3 -# QUEST__RESET__URL= -# QUEST__RESET__QUESTIONID= +QUEST__RESET_PASSWORD__API_KEY=dummy3 +# QUEST__RESET_PASSWORD__URL= +# QUEST__RESET_PASSWORD__QUESTIONID= # --- Other Overrides (Optional) --- # Example of overriding a nested value in the AUTH dictionary # AUTH__OTP_TIMEOUT=60 +# Example of overriding web size constraints +# WEB__COURSE__PAGE_SIZE=5 +# WEB__REVIEW__PAGE_SIZE=10 +# WEB__REVIEW__COMMENT_MIN_LENGTH=30 # Example of overriding a list with a comma-separated string # ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com ``` -### YAML +## `config.yaml.example` -- `config.yaml` is used to set custom but not secret configs (e.g. frontend and backend URLs, questionnaire ID) -- Copy this `config.yaml.example` file to `config.yaml` and fill in the required fields. -- `config.yaml` should **NOT** be committed (already git ignored). - -```yaml path=config.yaml +```yaml # Please copy this file to config.yaml and fill in # corresponding fields. # For non-secret, environment-specific configuration. @@ -108,6 +171,13 @@ CORS_ALLOWED_ORIGINS: # COOKIE_AGE: 2592000 # 30 days # SAVE_EVERY_REQUEST: true # +# WEB: +# COURSE: +# PAGE_SIZE: 5 +# REVIEW: +# PAGE_SIZE: 10 +# COMMENT_MIN_LENGTH: 30 +# # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 @@ -116,6 +186,10 @@ CORS_ALLOWED_ORIGINS: # PASSWORD_LENGTH_MIN: 10 # PASSWORD_LENGTH_MAX: 32 # EMAIL_DOMAIN_NAME: "sjtu.edu.cn" +# ACTION_LIST: +# - "signup" +# - "login" +# - "reset_password" # # DATABASE: # URL: Use env @@ -136,21 +210,19 @@ QUEST: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy1" QUESTIONID: 10000001 - RESET: + RESET_PASSWORD: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy2" QUESTIONID: 10000002 + # AUTO_IMPORT_CRAWLED_DATA: true ``` -### Default Config +## Built-in defaults -- Just for example. -- `settings.py` should **NOT** be modified by non-developers. -- The fields whose default values are `None` are mandatory, either in env or in `config.yaml`. +Current built-in defaults are: -```python path=website/settings.py -# --- Default Configuration --- +```python DEFAULTS = { "DEBUG": True, "SECRET_KEY": None, @@ -160,6 +232,10 @@ DEFAULTS = { "COOKIE_AGE": 2592000, # 30 days "SAVE_EVERY_REQUEST": True, }, + "WEB": { + "COURSE": {"PAGE_SIZE": 10}, + "REVIEW": {"PAGE_SIZE": 10, "COMMENT_MIN_LENGTH": 30}, + }, "AUTH": { "OTP_TIMEOUT": 120, "TEMP_TOKEN_TIMEOUT": 600, @@ -168,6 +244,7 @@ DEFAULTS = { "PASSWORD_LENGTH_MIN": 10, "PASSWORD_LENGTH_MAX": 32, "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", + "ACTION_LIST": ["signup", "login", "reset_password"], }, "DATABASE": {"URL": "sqlite:///db.sqlite3"}, "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, @@ -184,7 +261,7 @@ DEFAULTS = { "URL": None, "QUESTIONID": None, }, - "RESET": { + "RESET_PASSWORD": { "API_KEY": None, "URL": None, "QUESTIONID": None, @@ -193,3 +270,19 @@ DEFAULTS = { "AUTO_IMPORT_CRAWLED_DATA": True, } ``` + +## Production notes + +For production, usually: + +- set `DEBUG: false` +- set real backend domains in `ALLOWED_HOSTS` +- set real frontend domains in `CORS_ALLOWED_ORIGINS` +- use a strong `SECRET_KEY` +- use real Postgres and Valkey URLs +- keep secrets in environment variables, not in `config.yaml` + +Typical production split: + +- `/etc/coursereview/secrets.env` for secrets +- `/etc/coursereview/config.yaml` for non-secret config diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index 48a252b..0000000 --- a/docs/setup.md +++ /dev/null @@ -1,125 +0,0 @@ -# Development - -Environment: - -- Ubuntu Linux (most modern Linux distros and MacOS are supposedly supported.) - -- Use your corresponding package manager. This guide uses ubuntu/debian's `apt`, python `uv`, modern javascript runtime and package manager `bun`. - -- python 3.10 to 3.13 - ---- - -1. `git clone git@github.com:TechJI-2023/CourseReview.git` - -2. `cd CourseReview` - -3. `git checkout dev` - -4. `uv sync --all-groups` - -5. `uv run prek install` (for installing git hook in .git) - -6. Make directory for builds of static files: `mkdir staticfiles` - -7. cp .env.example and rename it .env at root dir. The contents of PostgreSQL should be like: - - ```ini - # PostgreSQL - DB_USER=admin - DB_PASSWORD=test - DB_HOST=127.0.0.1 - DB_PORT=5432 - REDIS_URL=redis://localhost:6379/0 - SECRET_KEY=02247f40-a769-4c49-9178-4c038048e7ad - DEBUG=True - OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 - ``` - - Also cp .env.example in frontend/ and rename it .env. - -8. Build static files: `make collect` - -9. Configure database - 1. Install Postgres: - - `sudo apt update` - - - `sudo apt install postgresql` - - 2. Create user postgres: `sudo -iu postgres` - - 3. Initialize database: `initdb -D /var/lib/postgres/data` - - 4. Start postgresql service: `sudo systemctl start postgresql`. Run `sudo systemctl enable postgresql` to auto-start postgresql service on start-up. - - 5. Switch to user postgres: `sudo -iu postgres` - - 6. `psql` - 1. Initialize coursereview database, user and privileges - - ```sql - CREATE DATABASE coursereview; - CREATE USER admin WITH PASSWORD 'test'; -- This is the same password of admin in .env file above. - GRANT ALL PRIVILEGES ON DATABASE coursereview TO admin; - ALTER DATABASE coursereview OWNER TO admin; - ``` - - 2. Get the path of config file: - - ```sql - SHOW config_file; - ``` - - 3. Copy the path (Ctrl+Shift+C by default) - - 4. Exit `psql` and switch back to normal user: `\q`, `exit` - - 7. Configure postgres to listen on all interfaces (DO NOT do this in production): `sudo vim {Path to your config file}`, example: `sudo vim /etc/postgresql/14/main/postgresql.conf`. Find the line `listen_addresses`, modify it to: - - ```ini - listen_addresses = '0.0.0.0' - ``` - - 8. Grant permission to connect to postgres from any IP (DO NOT do this in production): `sudo vim /etc/postgresql/14/main/pg_hba.conf` (maybe differ from your path, just change the command according to the copied path) and add a line: - - ```ini - host all all 0.0.0.0/0 md5 - ``` - - 9. Restart postgres service: `sudo systemctl restart postgresql` - - 10. Auto setup database connection and static file routes in Django: `make migrate`, `make makemigrations` - -10. Install cache database valkey: `sudo apt install valkey`, `sudo systemctl start valkey`. Run `sudo systemctl enable valkey` to auto-start valkey service on start-up. - -11. `make run` and visit - -12. Add local admin: - 1. `make createsuperuser`. The email can be blank. Use a strong password in production. - - 2. Enter interactive python shell: `make shell`. (Different from directly running `python` from shell.) - - 3. Run following python codes in interactive shell: - - ```python - from django.contrib.auth.models import User - u = User.objects.last() - u.is_active = True - u.is_staff = True - u.is_admin = True - u.save() - ``` - -13. Crawl data from JI official website: - 1. Edit `COURSE_DETAIL_URL_PREFIX` in `apps/spider/crawlers/orc.py`: Add a number after url param `id` like this: `...?id=23`, so only course id starting from 23 (e.g. 230-239, 2300) will be crawled, so as to save time during development. Remember not to commit this change. - - 2. Enter interactive python shell: `make shell`. - - 3. Run following python codes in interactive shell: - - ```python - from scripts import crawl_and_import_data - crawl_and_import_data() - ``` - -14. Run frontend (dev mode): `make dev-frontend` and visit http://127.0.0.1:5173/ diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..ac86635 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,283 @@ +# Setup and Operations + +This project has: + +- a Django backend +- a separate frontend repo deployed to Cloudflare Pages + +The backend does **not** build or serve the frontend app. +It only serves the API and Django admin. + +## Tech stack + +- Python 3.14 +- Django 6 +- PostgreSQL 18 +- Valkey 9 +- Podman +- uv +- ruff +- ty + +## Prerequisites + +Install: + +- `uv` +- `podman` +- `podman-compose` (required by `podman compose`) + +## Repository setup + +```bash +git clone git@github.com:Tech-JI/CourseReview.git +cd CourseReview +./run.py init +``` + +`./run.py init` will: + +- create `.venv` +- install dependencies +- install hooks +- create `.env` from `.env.example` if missing +- create `config.yaml` from `config.yaml.example` if missing + +Then edit: + +- `.env` +- `config.yaml` + +See `docs/config.md` for details. + +## Local development + +Recommended workflow: + +- run Postgres and Valkey in containers +- run Django on the host + +Start everything: + +```bash +./run.py dev +``` + +This will: + +- start `db` and `cache` +- run migrations +- start Django at `127.0.0.1:8000` + +### Common commands + +- Start only infra: + + ```bash + ./run.py infra up + ``` + +- Stop infra: + + ```bash + ./run.py infra stop + ``` + +- Destroy infra: + + ```bash + ./run.py infra destroy + ``` + +- Run migrations: + + ```bash + ./run.py django migrate + ``` + +- Create migrations: + + ```bash + ./run.py django makemigrations + ``` + +- Create superuser: + + ```bash + ./run.py django createsuperuser + ``` + +- Open Django shell: + + ```bash + ./run.py django shell + ``` + +- Run tests: + + ```bash + ./run.py test + ``` + +- Pass extra pytest arguments: + + ```bash + ./run.py test -- -q + ``` + +### Why `.env` uses `db` and `cache` + +Keep this in `.env`: + +```env +DATABASE__URL=postgres://admin:test@db:5432/coursereview +REDIS__URL=redis://cache:6379/0 +``` + +This works for both cases: + +- Podman Compose uses `db` and `cache` directly +- host-side commands automatically rewrite them to `127.0.0.1` + +So do not switch `.env` back and forth between `db` and `localhost`. + +## Full container stack + +If you want to run the entire backend stack in containers: + +```bash +./run.py stack up --mode dev --build +``` + +Run migrations in the stack: + +```bash +./run.py stack migrate --mode dev +``` + +Inspect services: + +```bash +./run.py stack ps --mode dev +./run.py stack logs --mode dev +``` + +Stop the stack: + +```bash +./run.py stack down --mode dev +``` + +## CI testing + +Tests are run with `pytest`. + +Locally: + +```bash +./run.py test +``` + +In GitHub Actions, the workflow uses service containers for: + +- `db` +- `cache` + +and runs tests through `./run.py test`, so the same env normalization logic is used in CI and locally. + +## Production deployment + +Recommended production model: + +- build the image in GitHub Actions +- publish it to `ghcr.io` +- pull it on the server with Podman +- run containers with Quadlet +- keep secrets on the server +- deploy by image digest + +### Registry + +Image name: + +```text +ghcr.io/tech-ji/coursereview +``` + +Use GitHub Actions to publish images manually for now, instead of building on every push. + +### Server user + +Create a dedicated service user and group: + +- user: `coursereview` +- group: `coursereview` + +Enable lingering so rootless user services can run without login: + +```bash +sudo loginctl enable-linger coursereview +``` + +### Server config layout + +Recommended: + +```text +/etc/coursereview/ + secrets.env + config.yaml +``` + +Suggested ownership and permissions: + +- directory owned by `root:coursereview` +- `secrets.env` readable by group `coursereview` +- `config.yaml` readable by group `coursereview` + +### Quadlet + +Use rootless Quadlet units under: + +```text +~/.config/containers/systemd/ +``` + +for the `coursereview` user. + +Typical units: + +- one network +- one Postgres container +- one Valkey container +- one Django container + +### Deployment workflow + +Recommended deploy flow: + +1. trigger GitHub Actions to publish image +2. get the image digest +3. pull that exact image on the server +4. run migrations using that image +5. restart the backend service + +Prefer: + +```text +ghcr.io/tech-ji/coursereview@sha256:... +``` + +over floating tags like `latest`. + +### Migrations + +Run migrations explicitly during deploy, before restarting the backend. + +Do not rely on container startup to hide migration failures. + +## Static files and Django admin + +The Vue frontend is separate, but Django admin still needs Django static assets. + +So production still needs a static-files strategy for admin, typically `collectstatic` diff --git a/frontend b/frontend index 464bd99..4f5081a 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 464bd99514f4ae80a36107c4efc9663b526cc9bb +Subproject commit 4f5081afd82023924911f42b7f0e6b3763a372d9 diff --git a/pyproject.toml b/pyproject.toml index 90395bd..bdba2ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,24 +3,23 @@ name = "course-review" requires-python = ">=3.14, <3.15" version = "0.0.1" dependencies = [ - "beautifulsoup4==4.14.2", - "dj-database-url==3.0.1", - "django==5.2.8", - "django-debug-toolbar==6.1.0", - "httpx==0.28.1", - "psycopg2-binary==2.9.11", - "python-dateutil==2.9.0", - "python-dotenv==1.2.1", - "pytz==2025.2", - "redis==7.1.0", - "requests==2.32.5", - "bpython==0.26", - "greenlet==3.2.4", - "ptpython==3.0.31", - "djangorestframework==3.16.1", - "django-cors-headers==4.9.0", - "django-redis==6.0.0", - "pyyaml==6.0.3", + "beautifulsoup4>=4.14.0", + "bpython>=0.26", + "dj-database-url>=3.1.0", + "django>=6.0.0", + "django-cors-headers>=4.9.0", + "django-debug-toolbar>=6.3.0", + "django-redis>=6.0.0", + "djangorestframework>=3.17.0", + "gunicorn>=26.0.0", + "httpx>=0.28.0", + "psycopg[binary]>=3.3.0", + "python-dateutil>=2.9.0.post0", + "python-dotenv>=1.2.0", + "pytz>=2026.2", + "pyyaml>=6.0.0", + "redis>=7.4.0", + "requests>=2.34.0", ] [tool.uv] @@ -28,8 +27,17 @@ package = false [dependency-groups] dev = [ - "prek>=0.2.24", + "factory-boy>=3.3.0", + "prek>=0.4.0", + "pytest>=9.0.0", + "pytest-django>=4.12.0", ] lint = [ - "ruff==0.14.5", + "ruff>=0.15.0", ] +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "website.settings" +python_files = ["tests.py", "test_*.py", "*_tests.py"] +addopts = "--reuse-db --strict-markers --ignore=apps/web/tests/lib_tests" +testpaths = ["apps"] +pythonpath = ["."] diff --git a/run.py b/run.py new file mode 100755 index 0000000..0bf46a9 --- /dev/null +++ b/run.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python + +from __future__ import annotations + +import argparse +import os +import shlex +import shutil +import subprocess +import sys + +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import quote, unquote, urlparse, urlunparse + + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parent + +COMPOSE_BASE: Final[Path] = PROJECT_ROOT / "compose.yaml" +COMPOSE_DEV: Final[Path] = PROJECT_ROOT / "compose.dev.yaml" +COMPOSE_PROD: Final[Path] = PROJECT_ROOT / "compose.prod.yaml" + +ENV_EXAMPLE: Final[Path] = PROJECT_ROOT / ".env.example" +ENV_DEV: Final[Path] = PROJECT_ROOT / ".env" +CONFIG_EXAMPLE: Final[Path] = PROJECT_ROOT / "config.yaml.example" +CONFIG_DEV: Final[Path] = PROJECT_ROOT / "config.yaml" + + +class AppError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Exec: + podman: str + compose: str + uv: str | None + + +def _which(cmd: str) -> str | None: + return shutil.which(cmd) + + +def _require(cmd: str) -> str: + p = _which(cmd) + if p is None: + raise AppError(f"Required executable not found on PATH: {cmd!r}") + + return p + + +def _detect_exec() -> Exec: + podman = _require("podman") + # On Debian, `podman compose` is often provided by podman-compose anyway, + # but the real, common entrypoint is `podman-compose`. + compose = _which("podman-compose") or "podman compose" + uv = _which("uv") + + return Exec(podman=podman, compose=compose, uv=uv) + + +def _run( + argv: list[str], + *, + cwd: Path = PROJECT_ROOT, + env: dict[str, str] | None = None, + check: bool = True, +) -> None: + pretty = shlex.join(argv) + print(f"[run] {pretty}") + subprocess.run(argv, cwd=str(cwd), env=env, check=check) + + +def _parse_env_file(path: Path) -> dict[str, str]: + """ + Minimal .env parser: + - ignores blank lines and lines starting with '#' + - parses KEY=VALUE + - strips surrounding single/double quotes from VALUE + """ + + if not path.exists(): + return {} + + out: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + key = k.strip() + val = v.strip() + + if (val.startswith('"') and val.endswith('"')) or ( + val.startswith("'") and val.endswith("'") + ): + val = val[1:-1] + + if key: + out[key] = val + + return out + + +def _effective_env(*, env_file: Path | None) -> dict[str, str]: + """ + Merge env sources with the priority OS env > env_file + """ + + merged: dict[str, str] = {} + if env_file is not None: + merged.update(_parse_env_file(env_file)) + merged.update(os.environ) # OS env overrides + + return merged + + +def _build_netloc( + *, username: str | None, password: str | None, host: str, port: int | None +) -> str: + """ + Re-encode user/pass safely for the URL + """ + + userinfo = "" + if username: + userinfo = quote(username, safe="") + if password is not None: + userinfo += f":{quote(password, safe='')}" + userinfo += "@" + + hostport = host + if port is not None: + hostport = f"{host}:{port}" + + return f"{userinfo}{hostport}" + + +def _normalize_env_for_host(env: dict[str, str]) -> dict[str, str]: + """ + Rewrite db -> 127.0.0.1 and cache -> 127.0.0.1 + for running Django on the host. + """ + out = dict(env) + + db_url = out.get("DATABASE__URL") + if db_url: + p = urlparse(db_url) + if p.hostname == "db": + out["DATABASE__URL"] = _rewrite_url_host(db_url, new_host="127.0.0.1") + print("[info] Rewrote DATABASE__URL host db -> 127.0.0.1 for host commands") + + redis_url = out.get("REDIS__URL") + if redis_url: + p = urlparse(redis_url) + if p.hostname == "cache": + out["REDIS__URL"] = _rewrite_url_host(redis_url, new_host="127.0.0.1") + print("[info] Rewrote REDIS__URL host cache -> 127.0.0.1 for host commands") + + return out + + +def _normalize_env_for_compose(env: dict[str, str]) -> dict[str, str]: + """ + Rewrite localhost/127.0.0.1 -> db/cache + for running inside the container network. + """ + + out = dict(env) + + db_url = out.get("DATABASE__URL") + if db_url: + p = urlparse(db_url) + if p.hostname in {"127.0.0.1", "localhost"}: + out["DATABASE__URL"] = _rewrite_url_host(db_url, new_host="db") + print( + "[info] Rewrote DATABASE__URL host localhost -> db for container runs" + ) + + redis_url = out.get("REDIS__URL") + if redis_url: + p = urlparse(redis_url) + if p.hostname in {"127.0.0.1", "localhost"}: + out["REDIS__URL"] = _rewrite_url_host(redis_url, new_host="cache") + print( + "[info] Rewrote REDIS__URL host localhost -> cache for container runs" + ) + + return out + + +def _rewrite_url_host(url: str, *, new_host: str) -> str: + parsed = urlparse(url) + if not parsed.scheme or not parsed.hostname: + return url + + netloc = _build_netloc( + username=parsed.username, + password=parsed.password, + host=new_host, + port=parsed.port, + ) + return urlunparse( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + + +def _derive_postgres_env(env: dict[str, str]) -> dict[str, str]: + """ + If POSTGRES_* variables are absent, derive them from DATABASE__URL. + """ + + db_url = env.get("DATABASE__URL") + if not db_url: + return env + + parsed = urlparse(db_url) + if parsed.scheme not in {"postgres", "postgresql"}: + return env + + # path is "/dbname" + db_name = parsed.path.lstrip("/") if parsed.path else "" + user = unquote(parsed.username) if parsed.username else "" + password = unquote(parsed.password) if parsed.password else "" + host = parsed.hostname or "" + port = str(parsed.port) if parsed.port is not None else "" + + derived: dict[str, str] = {} + if "POSTGRES_DB" not in env and db_name: + derived["POSTGRES_DB"] = db_name + if "POSTGRES_USER" not in env and user: + derived["POSTGRES_USER"] = user + if "POSTGRES_PASSWORD" not in env and password: + derived["POSTGRES_PASSWORD"] = password + if "POSTGRES_HOST" not in env and host: + derived["POSTGRES_HOST"] = host + if "POSTGRES_PORT" not in env and port: + derived["POSTGRES_PORT"] = port + + if not derived: + return env + + # Do not print secrets; just say what we derived. + safe_keys = ", ".join(sorted(derived.keys())) + print(f"[info] Derived from DATABASE__URL: {safe_keys}") + + out = dict(env) + out.update(derived) + + return out + + +def _compose_argv(exec_: Exec, *, mode: str, args: list[str]) -> list[str]: + files = [COMPOSE_BASE] + if mode == "dev": + files.append(COMPOSE_DEV) + elif mode == "prod": + files.append(COMPOSE_PROD) + else: + raise AppError(f"Unknown mode: {mode!r}") + + for f in files: + if not f.exists(): + raise AppError(f"Compose file missing: {f}") + + if exec_.compose == "podman compose": + argv = ["podman", "compose"] + else: + argv = [exec_.compose] + + for f in files: + argv += ["-f", str(f)] + + argv += args + + return argv + + +def _warn_localhost_db_url_if_starting_backend( + env: dict[str, str], *, starting_backend: bool +) -> None: + if not starting_backend: + return + + db_url = env.get("DATABASE__URL", "") + if "@127.0.0.1" in db_url or "@localhost" in db_url: + print( + "[warn] DATABASE__URL points to localhost, but you are starting the backend container.\n" + " Inside containers, localhost refers to the container itself.\n" + " Use a URL with host 'db' (e.g. postgres://user:pass@db:5432/name) for container runs." + ) + + +def cmd_init(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + if exec_.uv is None: + raise AppError( + "uv is required for init/dev workflows but was not found on PATH." + ) + + # Copy templates if missing + if ns.create_files: + if ENV_EXAMPLE.exists() and not ENV_DEV.exists(): + ENV_DEV.write_text( + ENV_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8" + ) + print("[info] Created .env from .env.example (edit secrets as needed).") + if CONFIG_EXAMPLE.exists() and not CONFIG_DEV.exists(): + CONFIG_DEV.write_text( + CONFIG_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8" + ) + print( + "[info] Created config.yaml from config.yaml.example (edit as needed)." + ) + + # Create venv + sync deps + if ns.sync: + _run([exec_.uv, "venv", ".venv"]) + _run([exec_.uv, "sync", "--all-groups"]) + if ns.install_prek: + _run([exec_.uv, "run", "prek", "install"]) + + +def cmd_infra(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + + env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file) + env = _effective_env(env_file=env_file) + env = _normalize_env_for_compose(env) + env = _derive_postgres_env(env) + + action = ns.action + if action == "down": + # 'down' is destructive in compose-land; for dev infra we want non-destructive. + print( + "[info] 'infra down' is treated as 'infra stop' (keeps DB volume). Use 'infra destroy' for a fresh DB." + ) + action = "stop" + + elif action == "up": + argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"]) + _run(argv, env=env) + + elif action == "stop": + argv = _compose_argv(exec_, mode="dev", args=["stop", "db", "cache"]) + _run(argv, env=env) + + elif action == "start": + # Start existing containers; if they don't exist yet, fall back to up. + argv = _compose_argv(exec_, mode="dev", args=["start", "db", "cache"]) + try: + _run(argv, env=env) + except subprocess.CalledProcessError: + argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"]) + _run(argv, env=env) + + elif action == "restart": + # Restart existing containers; if they don't exist yet, fall back to up. + argv = _compose_argv(exec_, mode="dev", args=["restart", "db", "cache"]) + try: + _run(argv, env=env) + except subprocess.CalledProcessError: + argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"]) + _run(argv, env=env) + + elif action == "destroy": + # Full teardown (may result in a fresh DB depending on podman-compose behavior/config). + argv = _compose_argv(exec_, mode="dev", args=["down"]) + _run(argv, env=env) + + elif action == "ps": + argv = _compose_argv(exec_, mode="dev", args=["ps"]) + _run(argv, env=env) + + else: + raise AppError(f"Unknown infra action: {ns.action!r}") + + +def _uv_run_manage(exec_: Exec, args: list[str], *, env_file: Path | None) -> None: + if exec_.uv is None: + raise AppError( + "uv is required for host Django commands but was not found on PATH." + ) + + env = _effective_env(env_file=env_file) + env = _normalize_env_for_host(env) + env = _derive_postgres_env(env) + _run([exec_.uv, "run", "django_manage.py", *args], env=env) + + +def _uv_run_pytest( + exec_: Exec, pytest_args: list[str], *, env_file: Path | None +) -> None: + if exec_.uv is None: + raise AppError("uv is required for host tests but was not found on PATH.") + + env = _effective_env(env_file=env_file) + env = _normalize_env_for_host(env) + env = _derive_postgres_env(env) + _run([exec_.uv, "run", "pytest", *pytest_args], env=env) + + +def cmd_django(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file) + + match ns.action: + case "migrate": + _uv_run_manage(exec_, ["migrate"], env_file=env_file) + case "makemigrations": + _uv_run_manage(exec_, ["makemigrations"], env_file=env_file) + case "shell": + _uv_run_manage(exec_, ["shell"], env_file=env_file) + case "createsuperuser": + _uv_run_manage(exec_, ["createsuperuser"], env_file=env_file) + case _: + raise AppError(f"Unknown django action: {ns.action!r}") + + +def cmd_test(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file) + + pytest_args = list(ns.pytest_args) + if pytest_args and pytest_args[0] == "--": + pytest_args = pytest_args[1:] + + _uv_run_pytest(exec_, pytest_args, env_file=env_file) + + +def cmd_dev(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file) + + if ns.infra: + cmd_infra(argparse.Namespace(action="up", env_file=str(env_file))) + + if ns.migrate: + cmd_django(argparse.Namespace(action="migrate", env_file=str(env_file))) + + if exec_.uv is None: + raise AppError("uv is required for dev server but was not found on PATH.") + + _uv_run_manage(exec_, ["runserver", ns.addr], env_file=env_file) + + +def cmd_hooks(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + if exec_.uv is None: + raise AppError("uv is required to run prek but was not found on PATH.") + + _run([exec_.uv, "run", "prek", "-a"]) + + +def cmd_image(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + tag = ns.tag + _run( + [exec_.podman, "build", "-f", "Containerfile", "-t", tag, "."], cwd=PROJECT_ROOT + ) + + +def cmd_stack(ns: argparse.Namespace) -> None: + exec_ = _detect_exec() + + env_file: Path | None + if ns.env_file is None: + env_file = ENV_DEV if ns.mode == "dev" else None + else: + env_file = Path(ns.env_file) + + env = _effective_env(env_file=env_file) + env = _normalize_env_for_compose(env) + env = _derive_postgres_env(env) + + starting_backend = ns.action in {"up", "restart"} and not ns.only_infra + _warn_localhost_db_url_if_starting_backend(env, starting_backend=starting_backend) + + if ns.action == "up": + args = ["up", "-d"] + if ns.build: + args.append("--build") + if ns.only_infra: + args += ["db", "cache"] + argv = _compose_argv(exec_, mode=ns.mode, args=args) + _run(argv, env=env) + + elif ns.action == "down": + argv = _compose_argv(exec_, mode=ns.mode, args=["down"]) + _run(argv, env=env) + + elif ns.action == "migrate": + argv = _compose_argv(exec_, mode=ns.mode, args=["run", "--rm", "migrate"]) + _run(argv, env=env) + + elif ns.action == "ps": + argv = _compose_argv(exec_, mode=ns.mode, args=["ps"]) + _run(argv, env=env) + + elif ns.action == "logs": + argv = _compose_argv(exec_, mode=ns.mode, args=["logs", "-f"]) + _run(argv, env=env, check=False) + + else: + raise AppError(f"Unknown stack action: {ns.action!r}") + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="run.py", description="Project management utility (dev/prod)." + ) + sub = p.add_subparsers(dest="cmd", required=True) + + p_init = sub.add_parser( + "init", help="Bootstrap local dev environment (.venv, deps, hooks, templates)." + ) + p_init.add_argument( + "--no-sync", dest="sync", action="store_false", help="Skip uv venv/sync." + ) + p_init.add_argument( + "--no-prek", + dest="install_prek", + action="store_false", + help="Skip prek install.", + ) + p_init.add_argument( + "--no-create-files", + dest="create_files", + action="store_false", + help="Skip copying example files.", + ) + p_init.set_defaults(sync=True, install_prek=True, create_files=True, func=cmd_init) + + p_infra = sub.add_parser( + "infra", help="Manage dev infra containers (db/cache only)." + ) + p_infra.add_argument( + "action", choices=["up", "start", "stop", "restart", "down", "destroy", "ps"] + ) + p_infra.add_argument( + "--env-file", + default=None, + help="Env file for substitution/derivation (default: .env).", + ) + p_infra.set_defaults(func=cmd_infra) + + p_dj = sub.add_parser( + "django", help="Run Django management commands on host (via uv)." + ) + p_dj.add_argument( + "action", choices=["migrate", "makemigrations", "shell", "createsuperuser"] + ) + p_dj.add_argument( + "--env-file", default=None, help="Env file used for Django (default: .env)." + ) + p_dj.set_defaults(func=cmd_django) + + p_test = sub.add_parser( + "test", + help="Run pytest on host (via uv) with host env normalization.", + ) + p_test.add_argument( + "--env-file", default=None, help="Env file used for tests (default: .env)." + ) + p_test.add_argument( + "pytest_args", + nargs=argparse.REMAINDER, + help="Arguments passed through to pytest. Use '--' before pytest flags.", + ) + p_test.set_defaults(func=cmd_test) + + p_dev = sub.add_parser( + "dev", + help="Run Django dev server on host (optionally start infra and migrate).", + ) + p_dev.add_argument("--addr", default="127.0.0.1:8000") + p_dev.add_argument("--no-infra", dest="infra", action="store_false") + p_dev.add_argument("--no-migrate", dest="migrate", action="store_false") + p_dev.add_argument( + "--env-file", default=None, help="Env file used for Django (default: .env)." + ) + p_dev.set_defaults(infra=True, migrate=True, func=cmd_dev) + + p_hooks = sub.add_parser("hooks", help="Run all prek hooks on all files.") + p_hooks.set_defaults(func=cmd_hooks) + + p_img = sub.add_parser("image", help="Container image operations.") + img_sub = p_img.add_subparsers(dest="action", required=True) + p_build = img_sub.add_parser("build", help="Build the backend container image.") + p_build.add_argument("--tag", default="coursereview-backend") + p_build.set_defaults(func=cmd_image) + + p_stack = sub.add_parser("stack", help="Manage full container stack (dev/prod).") + p_stack.add_argument("action", choices=["up", "down", "migrate", "ps", "logs"]) + p_stack.add_argument("--mode", choices=["dev", "prod"], default="dev") + p_stack.add_argument( + "--build", + action="store_true", + help="Build images when bringing up the stack (dev only usually).", + ) + p_stack.add_argument( + "--only-infra", + action="store_true", + help="Only start db/cache (ignore backend).", + ) + p_stack.add_argument( + "--env-file", + default=None, + help="Env file to load into the *compose process* for interpolation/derivation " + "(dev default: .env; prod default: none).", + ) + p_stack.set_defaults(func=cmd_stack) + + return p + + +def main(argv: list[str]) -> int: + try: + ns = _build_parser().parse_args(argv) + ns.func(ns) + return 0 + except AppError as e: + print(f"[error] {e}", file=sys.stderr) + return 2 + except subprocess.CalledProcessError as e: + return e.returncode + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py new file mode 100644 index 0000000..5982b09 --- /dev/null +++ b/scripts/entrypoint.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +import shutil +import subprocess +import sys + + +def main() -> None: + # If a command is provided (e.g. via compose `command:`), run it and exit. + # This enables: `command: ["python", "django_manage.py", "migrate"]` + if len(sys.argv) > 1: + subprocess.run(sys.argv[1:], check=True) + return + + if shutil.which("gunicorn"): + subprocess.run( + ["gunicorn", "website.wsgi:application", "--bind", "0.0.0.0:8000"], + check=True, + ) + else: + print("gunicorn not found.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 01be51a..dc4218e 100644 --- a/uv.lock +++ b/uv.lock @@ -13,59 +13,49 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, -] - -[[package]] -name = "appdirs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "asgiref" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] [[package]] name = "beautifulsoup4" -version = "4.14.2" +version = "4.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] [[package]] name = "blessed" -version = "1.25.0" +version = "1.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "jinxed" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/cd/eed8b82f1fabcb817d84b24d0780b86600b5c3df7ec4f890bcbb2371b0ad/blessed-1.25.0.tar.gz", hash = "sha256:606aebfea69f85915c7ca6a96eb028e0031d30feccc5688e13fd5cec8277b28d", size = 6746381, upload-time = "2025-11-18T18:43:52.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/48/27ed9ee1a574c96453a20f2024d385ec4c33ffeef180faab744c666e7dcd/blessed-1.42.0.tar.gz", hash = "sha256:34b460b77562ed21f807cfd7c527b983b0cc300c98810c8076f283b7bcd45ba7", size = 14025805, upload-time = "2026-05-20T16:03:18.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/2c/e9b6dd824fb6e76dbd39a308fc6f497320afd455373aac8518ca3eba7948/blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f", size = 95646, upload-time = "2025-11-18T18:43:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/d2/46/c41f906f2488e14ab65ac77101f9511779fb28c653484bd79ac13f2f21ee/blessed-1.42.0-py3-none-any.whl", hash = "sha256:f96c4a6dc664b48e0b832fa732acc16df67abd30f0ec35babf99025982f21852", size = 129863, upload-time = "2026-05-20T16:03:15.622Z" }, ] [[package]] @@ -87,36 +77,61 @@ wheels = [ [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -132,10 +147,9 @@ dependencies = [ { name = "django-debug-toolbar" }, { name = "django-redis" }, { name = "djangorestframework" }, - { name = "greenlet" }, + { name = "gunicorn" }, { name = "httpx" }, - { name = "psycopg2-binary" }, - { name = "ptpython" }, + { name = "psycopg", extra = ["binary"] }, { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "pytz" }, @@ -146,7 +160,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "factory-boy" }, { name = "prek" }, + { name = "pytest" }, + { name = "pytest-django" }, ] lint = [ { name = "ruff" }, @@ -154,29 +171,33 @@ lint = [ [package.metadata] requires-dist = [ - { name = "beautifulsoup4", specifier = "==4.14.2" }, - { name = "bpython", specifier = "==0.26" }, - { name = "dj-database-url", specifier = "==3.0.1" }, - { name = "django", specifier = "==5.2.8" }, - { name = "django-cors-headers", specifier = "==4.9.0" }, - { name = "django-debug-toolbar", specifier = "==6.1.0" }, - { name = "django-redis", specifier = "==6.0.0" }, - { name = "djangorestframework", specifier = "==3.16.1" }, - { name = "greenlet", specifier = "==3.2.4" }, - { name = "httpx", specifier = "==0.28.1" }, - { name = "psycopg2-binary", specifier = "==2.9.11" }, - { name = "ptpython", specifier = "==3.0.31" }, - { name = "python-dateutil", specifier = "==2.9.0" }, - { name = "python-dotenv", specifier = "==1.2.1" }, - { name = "pytz", specifier = "==2025.2" }, - { name = "pyyaml", specifier = "==6.0.3" }, - { name = "redis", specifier = "==7.1.0" }, - { name = "requests", specifier = "==2.32.5" }, + { name = "beautifulsoup4", specifier = ">=4.14.0" }, + { name = "bpython", specifier = ">=0.26" }, + { name = "dj-database-url", specifier = ">=3.1.0" }, + { name = "django", specifier = ">=6.0.0" }, + { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-debug-toolbar", specifier = ">=6.3.0" }, + { name = "django-redis", specifier = ">=6.0.0" }, + { name = "djangorestframework", specifier = ">=3.17.0" }, + { name = "gunicorn", specifier = ">=26.0.0" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.0" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "python-dotenv", specifier = ">=1.2.0" }, + { name = "pytz", specifier = ">=2026.2" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "redis", specifier = ">=7.4.0" }, + { name = "requests", specifier = ">=2.34.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "prek", specifier = ">=0.2.24" }] -lint = [{ name = "ruff", specifier = "==0.14.5" }] +dev = [ + { name = "factory-boy", specifier = ">=3.3.0" }, + { name = "prek", specifier = ">=0.4.0" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-django", specifier = ">=4.12.0" }, +] +lint = [{ name = "ruff", specifier = ">=0.15.0" }] [[package]] name = "curtsies" @@ -222,28 +243,28 @@ wheels = [ [[package]] name = "dj-database-url" -version = "3.0.1" +version = "3.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/05/2ec51009f4ce424877dbd8ad95868faec0c3494ed0ff1635f9ab53d9e0ee/dj_database_url-3.0.1.tar.gz", hash = "sha256:8994961efb888fc6bf8c41550870c91f6f7691ca751888ebaa71442b7f84eff8", size = 12556, upload-time = "2025-07-02T09:40:11.424Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/5e/86a43c6fdaa41c12d58e4ff3ebbfd6b71a7cb0360a08614e3754ef2e9afb/dj_database_url-3.0.1-py3-none-any.whl", hash = "sha256:43950018e1eeea486bf11136384aec0fe55b29fe6fd8a44553231b85661d9383", size = 8808, upload-time = "2025-07-02T09:40:26.326Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" }, ] [[package]] name = "django" -version = "5.2.8" +version = "6.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" }, + { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, ] [[package]] @@ -261,15 +282,15 @@ wheels = [ [[package]] name = "django-debug-toolbar" -version = "6.1.0" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/50/acae2dd379164f6f4c6b6b36fd48a4d21b02095a03f4df7c30a8d1f1a62c/django_debug_toolbar-6.1.0.tar.gz", hash = "sha256:e962ec350c9be8bdba918138e975a9cdb193f60ec396af2bb71b769e8e165519", size = 309141, upload-time = "2025-10-30T19:50:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/ea/b62673424dd72d2dbf5adf4145281a421d5792f47380d9bc8e3b11e1a769/django_debug_toolbar-6.3.0.tar.gz", hash = "sha256:f830a86fe02e17f625a22cfbed24a5bd1500762e201ec959c50efb0f9327282b", size = 334079, upload-time = "2026-04-02T16:07:01.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" }, + { url = "https://files.pythonhosted.org/packages/7d/9e/d8c3c845f4b5ccac7377c19f4049e7e00c6f121846a81f69a497b45734df/django_debug_toolbar-6.3.0-py3-none-any.whl", hash = "sha256:a199ce3d0f884739a9096835ad417479fede05f3b3c4824bc8b354721ba8f629", size = 298304, upload-time = "2026-04-02T16:06:59.617Z" }, ] [[package]] @@ -287,31 +308,77 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.16.1" +version = "3.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, + { url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" }, ] [[package]] name = "greenlet" -version = "3.2.4" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -353,155 +420,176 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] -name = "jedi" -version = "0.19.2" +name = "iniconfig" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinxed" -version = "1.3.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ansicon", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/eb/8821ce6e7386e96355f2c6be83944925b4a0870572896fd33a4e61b8aa5a/jinxed-2.0.0.tar.gz", hash = "sha256:64b960b8f8d9966e1b2e7cb57ea2b1aa0a8d7e68045b96dc7ef58201e43a1209", size = 127118, upload-time = "2026-05-08T21:25:25.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/b61668fd3b1e43b445979ec9a9e0af4781bf06884937d1e906f6a1be6dff/jinxed-2.0.0-py2.py3-none-any.whl", hash = "sha256:b3df1be5262a37145ef42875a8bbf918f1a563fbd035359650dd9fc0bb2b9294", size = 95364, upload-time = "2026-05-08T21:25:24.536Z" }, ] [[package]] -name = "parso" -version = "0.8.5" +name = "packaging" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "prek" -version = "0.2.24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/67/33ff75b530d8f189f18a06b38dc8f684d07ffca045e043293bf043dd963b/prek-0.2.24.tar.gz", hash = "sha256:f7588b9aa0763baf3b2e2bd1b9f103f43e74e494e3e3e12c71270118f56b3f3e", size = 273552, upload-time = "2025-12-23T03:59:10.059Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/bc/e67414efd29b81626016a16b7d9f33bb67f4adf47ea8554ae11b7fcb46e3/prek-0.2.24-py3-none-linux_armv6l.whl", hash = "sha256:2b36f04353cf0bbee35b510c83bf2a071682745be0d5265e821934a94869a7f7", size = 4793435, upload-time = "2025-12-23T03:59:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/3f/66/9a724e7b3e3a389e1e0cbacf0f4707ee056c83361925cadef43489b5012d/prek-0.2.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8149aa03eb993ba7c0a7509abccdf30665455db2405eb941c1c4174e3441c6b3", size = 4890722, upload-time = "2025-12-23T03:59:18.299Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/ee4c057f08a137ec85cc525f4170c3b930d8edd0a8ead20952c8079199c7/prek-0.2.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:100bf066669834876f87af11c79bdd4a3c8c1e8abf49aa047bc9c52265f5f544", size = 4615935, upload-time = "2025-12-23T03:59:20.947Z" }, - { url = "https://files.pythonhosted.org/packages/c4/71/a84ae24a82814896220fa3a03f07a62fb2e3f3ed6aa9c3952aaedb008b12/prek-0.2.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:656295670b7646936d5d738a708b310900870f47757375214dfaa592786702be", size = 4812259, upload-time = "2025-12-23T03:59:26.671Z" }, - { url = "https://files.pythonhosted.org/packages/55/9a/a009873b954f726f8f43be8d660095c76d47208c6e9397d75f916f52b8fc/prek-0.2.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3b79fe57f59fa2649d8a727152af742353de8d537ade75285bedf49b66bf8768", size = 4713078, upload-time = "2025-12-23T03:59:29.51Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/daf4a1da6f009f4413ca6302b6f6480f824be2447dc74606981c47958ad1/prek-0.2.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f02a79c76a84339eecc2d01b1e5f81eb4e8769629e9a62343a8e4089778db956", size = 5034136, upload-time = "2025-12-23T03:59:06.775Z" }, - { url = "https://files.pythonhosted.org/packages/49/17/2b754198c7444f9b8f09c60280e601659afb6a4d6ce9fc5553e15218800b/prek-0.2.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cbd9b7b568a5cdcb9ccce8c8b186b52c6547dfd2e70d0a2438e3cb17a37affb4", size = 5445865, upload-time = "2025-12-23T03:59:12.684Z" }, - { url = "https://files.pythonhosted.org/packages/67/61/d54c7db0f6ff1a12b0b7211b32b7b2685fcee81dd51fb1a139e757b648cd/prek-0.2.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc788a1bb3dba31c9ad864ee73fc6320c07fd0f0a3d9652995dfee5d62ccc4f8", size = 5401392, upload-time = "2025-12-23T03:59:24.181Z" }, - { url = "https://files.pythonhosted.org/packages/5a/61/cd7e78e2f371a6603c6ac323ad2306c6793d39f4f6ee2723682b25d65478/prek-0.2.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ee8d1293755f6b42e7fa4bbc7122781e7c3880ca06ed2f85f0ed40c0df14c9b", size = 5492942, upload-time = "2025-12-23T03:59:14.367Z" }, - { url = "https://files.pythonhosted.org/packages/10/ff/657c6269d65dbe682f82113620823c65e002c3ae4fd417f25adaa390179e/prek-0.2.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933a49f0b22abc2baca378f02b4b1b6d9522800a2ccc9e247aa51ebe421fc6dc", size = 5083804, upload-time = "2025-12-23T03:59:28.213Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d9/8929b12dd8849d4d00b6c8e22db1fec22fef4b1e7356c0812107eb0a4f6c/prek-0.2.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f88defe48704eea1391e29b18b363fcd22ef5490af619b6328fece8092c9d24b", size = 4819786, upload-time = "2025-12-23T03:59:32.053Z" }, - { url = "https://files.pythonhosted.org/packages/db/a4/d9e0f7d445621a5c416a8883a33b079cf2c6f7e35a360d15c074f9b353fb/prek-0.2.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3fd336eb13489460da3476bfb1bd185d6bd0f9d3f9bff7780b32d2c801026578", size = 4829112, upload-time = "2025-12-23T03:59:22.546Z" }, - { url = "https://files.pythonhosted.org/packages/10/da/4fdcd158268c337ad3fe4dad3fcb0716f46bba2fe202ee03a473e3eda9b9/prek-0.2.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:9eb952540fd17d540373eb4239ccdcc1e060ca1c33a7ec5d27f6ec03838848c5", size = 4698341, upload-time = "2025-12-23T03:59:11.184Z" }, - { url = "https://files.pythonhosted.org/packages/71/82/c9dd71e5c40c075314b6e3584067084dfbf56d9d1d74baea217d7581a5bf/prek-0.2.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7168d6d86576704cddb7c38aff1b62c305312700492c85ff981dfa986013c265", size = 4917027, upload-time = "2025-12-23T03:59:30.751Z" }, - { url = "https://files.pythonhosted.org/packages/ef/05/0559b0504d39dc97f71d74f270918d043f3259fff4cbe11beccfdbb586e6/prek-0.2.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4e500beb902c524b48d084deabc687cb344226ce91f926c6ab8a65a6754d8a9a", size = 5192231, upload-time = "2025-12-23T03:59:16.775Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b3/e740f52236a0077890a82e1c8046d4e0ff8b140bd3c30e3e82f35fee2224/prek-0.2.24-py3-none-win32.whl", hash = "sha256:bab279d54b6adf85d95923590dacaa9956eb354cc64204c45983fa2d5c2f7a8a", size = 4603284, upload-time = "2025-12-23T03:59:15.544Z" }, - { url = "https://files.pythonhosted.org/packages/41/31/cf0773b3cd7b965a7d15264ec96f85ee5f451db5e9df5d0d9d87d3b8e4ce/prek-0.2.24-py3-none-win_amd64.whl", hash = "sha256:c89ad7f73e8b38bd5e79e83fec3bf234dec87295957c94cc7d94a125bc609ff0", size = 5295275, upload-time = "2025-12-23T03:59:25.354Z" }, - { url = "https://files.pythonhosted.org/packages/97/34/b44663946ea7be1d0b1c7877e748603638a8d0eff9f3969f97b9439aa17b/prek-0.2.24-py3-none-win_arm64.whl", hash = "sha256:9257b3293746a69d600736e0113534b3b80a0ce8ee23a1b0db36253e9c7e24ab", size = 4962129, upload-time = "2025-12-23T03:59:08.609Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/01/1d2c238c6f226d75881cd7a5532e980f4d524babc3c034d16ad89e88b6e1/prek-0.4.1.tar.gz", hash = "sha256:622a8812bda87cf4ddcae2dab5ccecc55b88d70c677129dbe25e975d923179f0", size = 452606, upload-time = "2026-05-20T04:27:19.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/ca/0274343faf2672d649b1e648053d3cb48fdfef7a390b43713d95880ebb67/prek-0.4.1-py3-none-linux_armv6l.whl", hash = "sha256:10e7e78ffe65dfba7d687a8c71b2f473554d1ba60f43c742105da4c0030feed9", size = 5515584, upload-time = "2026-05-20T04:27:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/6a067f530194a6e4141c36463eece92356dfd7f924ffe0cbf456bdca723b/prek-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b25807e0aa57d2118747e127b58e7a1bf41d5d7b3323f5f3f1f3cb10031245cc", size = 5878925, upload-time = "2026-05-20T04:27:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3d/a334c0f5b88fadca888eadfc1fb3d7f1dc8358b1a534d0987339ecb8eb92/prek-0.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:efa95331c4c171a867c0064c19d8a4abc94a1c1c920c8b8092f2d7d87f4b90a8", size = 5440994, upload-time = "2026-05-20T04:27:40.578Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3b/fa6eb635495c3576e65d7f42a48b9fdf4926dd052010df506ed98e9f9680/prek-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d1805123ab5d730629de588bf319ea39e7078b589b3288c95740f1b4780a1d4", size = 5692369, upload-time = "2026-05-20T04:27:23.184Z" }, + { url = "https://files.pythonhosted.org/packages/70/cb/9d9078723b3facb40289444332ca82bf38c0e1db3b5a907af461aba12324/prek-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:051c442b570b53756225410240577bee1aeace6be52955dfacf45a9783223b36", size = 5430031, upload-time = "2026-05-20T04:27:27.475Z" }, + { url = "https://files.pythonhosted.org/packages/ff/96/2d8cc6b5425215cd0b610f1dcef3f6f0f23db2a2b85f1a6fca43b7e7fe24/prek-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76663998827a2cbc94f5e209319809655489b5bd1f8e70568a623372e80253f0", size = 5834244, upload-time = "2026-05-20T04:27:44.229Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/cce02f3ade48a6d4bffb25e5f0ac28d10928263b0a4f53ecc72954957f4e/prek-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ab3460641762edf128b1ec8e833ce7e9ae015d1268a894560cb90d3393a7527", size = 6711903, upload-time = "2026-05-20T04:27:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/ccd581b6222277a2aa095530844d5bb76db4547042f05a9cb649476bf904/prek-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e69a9c02ead38706a5d2a4ae209dccba08ccb5d0026e1d08e723c66ab964750", size = 6084138, upload-time = "2026-05-20T04:27:46.549Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b7/6164a7dc6bb4796cfc19445be798302cc7625b62e2bec89ffb4272d7f983/prek-0.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dc744fedf98df8a00a9e3bcd629b163fee5e9f9e22bce66029d9945241586165", size = 5698950, upload-time = "2026-05-20T04:27:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/96/40/8151d6445a0f41ad60e979db39d8b0c6b074aad919cf5c73233281f0dff1/prek-0.4.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c0877e82c52359d655fe1072b3a5228639184d1d5f03c6803b6530cd6da1ef20", size = 5538662, upload-time = "2026-05-20T04:27:15.045Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/1f9892a45bb2dc8a3b4b89eb08f5de1cf745fcd7df9e535463ba4d41cebe/prek-0.4.1-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60928d1dad45ff3e491d3083a50643cc213aa2d54f1dbd8d702d7193773c020e", size = 5406581, upload-time = "2026-05-20T04:27:21.101Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b8/94ddac155b502859e4dc7943db99fa7fffecfa3878a2ef11726a8e72fad0/prek-0.4.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17ffa9d8dd40791b9b99cafe558c5cc28e78e5be57607b280b15f0dab90264e9", size = 5688880, upload-time = "2026-05-20T04:27:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e93d3853d1bdc06b281fff2aaf4106e19610fe5187c67c9ff13195f2df59/prek-0.4.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cdf4503a240369f66321213d9c4bc6f925014b64ff7121de9e9920c9b9838ce2", size = 6203536, upload-time = "2026-05-20T04:27:42.366Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/760969d6bfc77e3eba04f6c3801c81076e96a908a6c277c142a4b0f31f4e/prek-0.4.1-py3-none-win32.whl", hash = "sha256:7c515492ef3585e6bcd7b83f1bb1cb131038abc88ed2c843de1e4c3ceb865b19", size = 5208995, upload-time = "2026-05-20T04:27:38.331Z" }, + { url = "https://files.pythonhosted.org/packages/89/12/d43daf290a73dbc3e1a3eabb9077e45df661923949bee045de67cbe82524/prek-0.4.1-py3-none-win_amd64.whl", hash = "sha256:8fa707971465d8ad021c907e43691aad7bb98942943e61e294ece5f95d9fbc78", size = 5591734, upload-time = "2026-05-20T04:27:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/ab/36/2ab7647fe1e84bba2baae7f04de241197eed62683fb3085e164de266d111/prek-0.4.1-py3-none-win_arm64.whl", hash = "sha256:5b4a348537924b20e208cbd87ef58e96ec37d691c5bec2969209c40de0ecf72e", size = 5423147, upload-time = "2026-05-20T04:27:17.023Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] [[package]] -name = "psycopg2-binary" -version = "2.9.11" +name = "psycopg-binary" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] [[package]] -name = "ptpython" -version = "3.0.31" +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appdirs" }, - { name = "jedi" }, - { name = "prompt-toolkit" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/55/6275ed7bcfc146719ecbe22291054c18847c464285854265ee516a5b4c8b/ptpython-3.0.31.tar.gz", hash = "sha256:4fed0be42bad01b7c299922cf262f51d8a77c9c8ab8e261c902e981a57439c13", size = 73045, upload-time = "2025-08-27T15:30:11.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/18/3d9874ef021a9df79e1f0fc971f4e990cee55750c8bc9fe547a24c130009/ptpython-3.0.31-py3-none-any.whl", hash = "sha256:ddd25fadb6f2ecd4469a699c068d2dcd40d77c7105922569bba6dc79c0523458", size = 67295, upload-time = "2025-08-27T15:30:09.984Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] -name = "pygments" -version = "2.19.2" +name = "pytest-django" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, ] [[package]] name = "python-dateutil" -version = "2.9.0" +version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/77/bd458a2e387e98f71de86dcc2ca2cab64489736004c80bc12b70da8b5488/python-dateutil-2.9.0.tar.gz", hash = "sha256:78e73e19c63f5b20ffa567001531680d939dc042bf7850431877645523c66709", size = 342990, upload-time = "2024-03-01T03:52:54.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/7f/98d6f9ca8b731506c85785bbb8806c01f5966a4df6d68c0d1cf3b16967e1/python_dateutil-2.9.0-py2.py3-none-any.whl", hash = "sha256:cbf2f1da5e6083ac2fbfd4da39a25f34312230110440f424a14c7558bb85d82e", size = 230495, upload-time = "2024-03-01T03:52:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "pytz" -version = "2025.2" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] @@ -541,16 +629,16 @@ wheels = [ [[package]] name = "redis" -version = "7.1.0" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -558,35 +646,34 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "ruff" -version = "0.14.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, - { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, - { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, - { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] @@ -598,31 +685,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "soupsieve" -version = "2.8" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] [[package]] @@ -636,27 +714,27 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.2" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "wcwidth" -version = "0.2.14" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] diff --git a/website/config.py b/website/config.py index fed93f5..3171b86 100644 --- a/website/config.py +++ b/website/config.py @@ -116,7 +116,7 @@ def get( try: value = reduce(operator.getitem, path, self._final_config) - except (KeyError, TypeError): + except KeyError, TypeError: if required: raise ImproperlyConfigured( f"Required setting '{key}' is not defined in any source."