vault backup: 2025-12-23 16:41:40
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m47s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m47s
This commit is contained in:
BIN
quiz/.coverage
Normal file
BIN
quiz/.coverage
Normal file
Binary file not shown.
@@ -21,6 +21,9 @@ def django_db_setup():
|
||||
'NAME': None,
|
||||
},
|
||||
}
|
||||
settings.PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
Binary file not shown.
@@ -5,6 +5,8 @@ description = "Medical quiz application with auto-import from Obsidian"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"django>=6.0.0",
|
||||
"mistune>=3.1.4",
|
||||
"python-frontmatter>=1.1.0",
|
||||
"watchdog>=6.0.0",
|
||||
]
|
||||
|
||||
@@ -13,5 +15,11 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
dev-dependencies = [
|
||||
"pytest>=9.0.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
"pytest-subtests>=0.15.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ addopts =
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--reuse-db
|
||||
testpaths = tests
|
||||
testpaths = .
|
||||
markers =
|
||||
admin: Admin interface tests
|
||||
import: Import and parsing tests
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestAdminPages:
|
||||
email='admin@test.com',
|
||||
password='admin123'
|
||||
)
|
||||
client.login(username='testadmin', password='admin123')
|
||||
client.force_login(admin_user)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1,128 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from pathlib import Path
|
||||
from quiz.utils.importer import parse_matching_question
|
||||
|
||||
|
||||
class MatchingQuestionParserTests(TestCase):
|
||||
"""Tests for matching question parser"""
|
||||
|
||||
def test_parse_matching_5x5(self):
|
||||
"""Test parsing the real 5x5 matching question"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
|
||||
date: 2023-05-31
|
||||
---
|
||||
**Matcha rätt funktion med rätt lob:**
|
||||
(1p för alla rätt, inga delpoäng)
|
||||
- Smak
|
||||
- Syn
|
||||
- Somatosensorik
|
||||
- Motorik
|
||||
- Hörsel
|
||||
|
||||
- Lobus frontalis
|
||||
- Lobus Insularis
|
||||
- Lobus temporalis
|
||||
- Lobus parietalis
|
||||
- Lobus occipitalis
|
||||
|
||||
```spoiler-block:
|
||||
Smak: Lobus Insularis
|
||||
Syn: Lobus occipitalis
|
||||
Somatosensorik: Lobus parietalis
|
||||
Motorik: Lobus frontalis
|
||||
Hörsel: Lobus temporalis
|
||||
```
|
||||
"""
|
||||
is_matching, data = parse_matching_question(content)
|
||||
|
||||
self.assertTrue(is_matching)
|
||||
self.assertEqual(data['question_type'], 'matching')
|
||||
self.assertTrue(data['has_answer'])
|
||||
self.assertEqual(len(data['left_items']), 5)
|
||||
self.assertEqual(len(data['top_items']), 5)
|
||||
self.assertEqual(len(data['correct_pairs']), 5)
|
||||
|
||||
# Check specific items
|
||||
self.assertIn('Smak', data['left_items'])
|
||||
self.assertIn('Lobus frontalis', data['top_items'])
|
||||
|
||||
# Check correct pairs (Smak -> Lobus Insularis)
|
||||
# Smak is index 0, Lobus Insularis is index 1
|
||||
self.assertIn([0, 1], data['correct_pairs'])
|
||||
|
||||
def test_parse_matching_4x4(self):
|
||||
"""Test parsing a 4x4 matching question"""
|
||||
content = """---
|
||||
tags: [test, frågetyp/matching]
|
||||
date: 2023-01-01
|
||||
---
|
||||
Match the items:
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
- Item 4
|
||||
|
||||
- Match A
|
||||
- Match B
|
||||
- Match C
|
||||
- Match D
|
||||
|
||||
```spoiler-block:
|
||||
Item 1: Match B
|
||||
Item 2: Match A
|
||||
Item 3: Match D
|
||||
Item 4: Match C
|
||||
```
|
||||
"""
|
||||
is_matching, data = parse_matching_question(content)
|
||||
|
||||
self.assertTrue(is_matching)
|
||||
self.assertEqual(len(data['left_items']), 4)
|
||||
self.assertEqual(len(data['top_items']), 4)
|
||||
self.assertEqual(len(data['correct_pairs']), 4)
|
||||
|
||||
def test_parse_matching_todo(self):
|
||||
"""Test parsing matching question with TODO answer"""
|
||||
content = """---
|
||||
tags: [test, frågetyp/matching]
|
||||
date: 2023-01-01
|
||||
---
|
||||
Match the items:
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
|
||||
- Match A
|
||||
- Match B
|
||||
|
||||
```spoiler-block:
|
||||
TODO
|
||||
```
|
||||
"""
|
||||
is_matching, data = parse_matching_question(content)
|
||||
|
||||
self.assertTrue(is_matching)
|
||||
self.assertFalse(data['has_answer'])
|
||||
self.assertEqual(len(data['correct_pairs']), 0)
|
||||
|
||||
def test_parse_matching_no_answer(self):
|
||||
"""Test parsing matching question without answer block"""
|
||||
content = """---
|
||||
tags: [test, frågetyp/matching]
|
||||
date: 2023-01-01
|
||||
---
|
||||
Match the items:
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
|
||||
- Match A
|
||||
- Match B
|
||||
"""
|
||||
is_matching, data = parse_matching_question(content)
|
||||
|
||||
self.assertTrue(is_matching)
|
||||
self.assertFalse(data['has_answer'])
|
||||
self.assertEqual(len(data['correct_pairs']), 0)
|
||||
85
quiz/quiz/tests/test_quiz_creation.py
Normal file
85
quiz/quiz/tests/test_quiz_creation.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from quiz.models import Course, Tag, Question, Exam, QuizUser, QuizSession
|
||||
from quiz.forms import CreateQuizForm
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestQuizCreation:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_data(self, client):
|
||||
# Clear database to ensure fresh state
|
||||
Question.objects.all().delete()
|
||||
Tag.objects.all().delete()
|
||||
QuizSession.objects.all().delete()
|
||||
Course.objects.all().delete()
|
||||
|
||||
self.client = client
|
||||
s = self.client.session
|
||||
s.save()
|
||||
self.user = QuizUser.objects.create(session_key=s.session_key)
|
||||
|
||||
self.course1 = Course.objects.create(name="Course 1", code="C1")
|
||||
self.course2 = Course.objects.create(name="Course 2", code="C2")
|
||||
|
||||
self.tag1 = Tag.objects.create(name="Tag1", slug="tag-1")
|
||||
|
||||
self.exam1 = Exam.objects.create(course=self.course1, date="2023-01-01")
|
||||
self.exam2 = Exam.objects.create(course=self.course2, date="2023-01-02")
|
||||
|
||||
self.q1 = Question.objects.create(
|
||||
exam=self.exam1, text="UNIQUE_Q1_TEXT", correct_answer="A", file_path="path1"
|
||||
)
|
||||
self.q1.tags.add(self.tag1)
|
||||
|
||||
self.q2 = Question.objects.create(
|
||||
exam=self.exam2, text="UNIQUE_Q2_TEXT", correct_answer="A,B", file_path="path2"
|
||||
)
|
||||
|
||||
def test_create_quiz_form_valid(self):
|
||||
form_data = {
|
||||
'course': self.course1.id,
|
||||
'tags': [self.tag1.id],
|
||||
'question_type': ['single']
|
||||
}
|
||||
form = CreateQuizForm(data=form_data)
|
||||
assert form.is_valid()
|
||||
|
||||
def test_create_quiz_view_post(self):
|
||||
response = self.client.post(reverse('create_quiz'), {
|
||||
'course': self.course1.id,
|
||||
'tags': [self.tag1.id],
|
||||
'question_type': ['single']
|
||||
})
|
||||
|
||||
session = QuizSession.objects.get(user=self.user)
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse('quiz_mode', args=[session.id])
|
||||
|
||||
assert session.course.id == self.course1.id
|
||||
assert list(session.tags.values_list('id', flat=True)) == [self.tag1.id]
|
||||
assert session.question_types == ['single']
|
||||
|
||||
def test_get_next_question_filters(self):
|
||||
session = QuizSession.objects.create(user=self.user, course=self.course1)
|
||||
|
||||
response = self.client.get(reverse('next_question', args=[session.id]))
|
||||
assert response.status_code == 200
|
||||
assert "UNIQUE_Q1_TEXT" in response.content.decode()
|
||||
|
||||
# Now change filter to Course 2
|
||||
session.course = self.course2
|
||||
session.save()
|
||||
response = self.client.get(reverse('next_question', args=[session.id]))
|
||||
assert "UNIQUE_Q2_TEXT" in response.content.decode()
|
||||
|
||||
def test_filter_by_type(self):
|
||||
session = QuizSession.objects.create(user=self.user, question_types=['multi'])
|
||||
|
||||
response = self.client.get(reverse('next_question', args=[session.id]))
|
||||
assert "UNIQUE_Q2_TEXT" in response.content.decode()
|
||||
|
||||
session.question_types = ['single']
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('next_question', args=[session.id]))
|
||||
assert "UNIQUE_Q1_TEXT" in response.content.decode()
|
||||
@@ -10,8 +10,13 @@ class QuizViewsTestCase(TestCase):
|
||||
"""Set up test data"""
|
||||
self.client = Client()
|
||||
|
||||
# Create test user (QuizUser uses session_key, not username)
|
||||
self.user = QuizUser.objects.create(session_key="test_session_key_123")
|
||||
# Ensure session exists and get its key
|
||||
s = self.client.session
|
||||
s.save()
|
||||
session_key = s.session_key
|
||||
|
||||
# Create test user with the same session key
|
||||
self.user = QuizUser.objects.create(session_key=session_key)
|
||||
|
||||
# Create test course
|
||||
self.course = Course.objects.create(name="Test Course")
|
||||
@@ -24,14 +29,15 @@ class QuizViewsTestCase(TestCase):
|
||||
)
|
||||
|
||||
# Create test tags
|
||||
self.tag1 = Tag.objects.create(name="Tag 1")
|
||||
self.tag2 = Tag.objects.create(name="Tag 2")
|
||||
self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
|
||||
self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
|
||||
|
||||
# Create test questions
|
||||
self.question1 = Question.objects.create(
|
||||
text="Test question 1?",
|
||||
correct_answer="A",
|
||||
exam=self.exam
|
||||
exam=self.exam,
|
||||
file_path="test1.md"
|
||||
)
|
||||
self.question1.tags.add(self.tag1)
|
||||
|
||||
@@ -41,7 +47,8 @@ class QuizViewsTestCase(TestCase):
|
||||
self.question2 = Question.objects.create(
|
||||
text="Test question 2 (multi)?",
|
||||
correct_answer="A,B",
|
||||
exam=self.exam
|
||||
exam=self.exam,
|
||||
file_path="test2.md"
|
||||
)
|
||||
self.question2.tags.add(self.tag1, self.tag2)
|
||||
|
||||
@@ -58,7 +65,7 @@ class QuizViewsTestCase(TestCase):
|
||||
"""Test dashboard index view"""
|
||||
response = self.client.get(reverse('index'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Välkommen')
|
||||
self.assertContains(response, 'Snabbstart')
|
||||
self.assertIn('active_sessions', response.context)
|
||||
self.assertIn('form', response.context)
|
||||
|
||||
0
quiz/quiz/utils/tests/__init__.py
Normal file
0
quiz/quiz/utils/tests/__init__.py
Normal file
261
quiz/quiz/utils/tests/test_importer.py
Normal file
261
quiz/quiz/utils/tests/test_importer.py
Normal file
@@ -0,0 +1,261 @@
|
||||
import pytest
|
||||
import time
|
||||
from pathlib import Path
|
||||
from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats
|
||||
from quiz.models import Question, Option
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestMarkdownParsing:
|
||||
"""Test parsing of various Obsidian markdown question formats"""
|
||||
|
||||
def test_parse_single_choice_question(self):
|
||||
"""Test parsing standard single choice question (SCQ)"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/scq, anatomi]
|
||||
date: 2022-01-15
|
||||
---
|
||||
What is the correct answer?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Wrong answer
|
||||
- B: Correct answer
|
||||
- C: Another wrong
|
||||
|
||||
```spoiler-block:
|
||||
B
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['text'] == 'What is the correct answer?'
|
||||
assert data['correct_answer'] == 'B'
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'scq'
|
||||
assert len(data['options']) == 3
|
||||
assert data['options'][0] == ('A', 'Wrong answer')
|
||||
assert data['options'][1] == ('B', 'Correct answer')
|
||||
|
||||
def test_parse_multiple_choice_question(self):
|
||||
"""Test parsing multiple choice question (MCQ) with 'och' separator"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Vilka av följande räknas till storhjärnans basala kärnor?
|
||||
|
||||
**Välj två alternativ**
|
||||
- A: Putamen
|
||||
- B: Nucleus Ruber
|
||||
- C: Substantia nigra
|
||||
- D: Nucleus caudatus
|
||||
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert 'Vilka av följande' in data['text']
|
||||
assert data['correct_answer'] == 'A,D' # Normalized to comma-separated
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'mcq'
|
||||
assert len(data['options']) == 4
|
||||
|
||||
def test_parse_multiple_choice_comma_separated(self):
|
||||
"""Test MCQ with comma-separated answer"""
|
||||
content = """---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Select two options:
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
- C: Option C
|
||||
- D: Option D
|
||||
|
||||
```spoiler-block:
|
||||
B, C
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert data['correct_answer'] == 'B,C'
|
||||
assert data['has_answer'] is True
|
||||
|
||||
def test_parse_matching_question(self):
|
||||
"""Test parsing matching question (DND/Matching)"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
|
||||
date: 2023-05-31
|
||||
---
|
||||
**Matcha rätt funktion med rätt lob:**
|
||||
(1p för alla rätt, inga delpoäng)
|
||||
- Smak
|
||||
- Syn
|
||||
- Somatosensorik
|
||||
- Motorik
|
||||
- Hörsel
|
||||
|
||||
**Alternativ:**
|
||||
|
||||
- Lobus frontalis
|
||||
- Lobus Insularis
|
||||
- Lobus temporalis
|
||||
- Lobus parietalis
|
||||
- Lobus occipitalis
|
||||
|
||||
```spoiler-block:
|
||||
Smak: Lobus Insularis
|
||||
Syn: Lobus occipitalis
|
||||
Somatosensorik: Lobus parietalis
|
||||
Motorik: Lobus frontalis
|
||||
Hörsel: Lobus temporalis
|
||||
```
|
||||
"""
|
||||
is_matching, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_matching is True
|
||||
assert data['question_type'] == 'matching'
|
||||
assert data['has_answer'] is True
|
||||
assert len(data['left_items']) == 5
|
||||
assert len(data['top_items']) == 5
|
||||
assert len(data['correct_pairs']) == 5
|
||||
|
||||
def test_parse_textalternativ_question(self):
|
||||
"""Test text alternative question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textalternativ, öga, anatomi]
|
||||
---
|
||||
Svara på följande frågor:
|
||||
|
||||
a) Bokstaven B sitter i en lob, vilken?
|
||||
- Lobus temporalis
|
||||
- Lobus frontalis
|
||||
- Lobus parietalis
|
||||
|
||||
b) Vilket funktionellt centra återfinns där?
|
||||
- Syncentrum
|
||||
- Motorcentrum
|
||||
- Somatosensoriskt centrum
|
||||
|
||||
```spoiler-block:
|
||||
a) Lobus parietalis
|
||||
b) Somatosensoriskt centrum
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textalternativ'
|
||||
assert data['has_answer'] is True
|
||||
assert 'Lobus parietalis' in data['correct_answer']
|
||||
assert 'Somatosensoriskt centrum' in data['correct_answer']
|
||||
|
||||
def test_parse_textfalt_question(self):
|
||||
"""Test text field (fill-in) question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textfält, öga]
|
||||
---
|
||||
**Fyll i rätt siffra!**
|
||||
|
||||
a) Vilken siffra pekar på gula fläcken?
|
||||
b) Vilken siffra pekar på choroidea?
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 6
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textfält'
|
||||
assert data['has_answer'] is True
|
||||
assert '7' in data['correct_answer']
|
||||
assert '6' in data['correct_answer']
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestQuestionImport:
|
||||
"""Test actual import of questions to database"""
|
||||
|
||||
def test_import_single_question(self, tmp_path):
|
||||
"""Test importing a single question file"""
|
||||
question_file = tmp_path / "question1.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Test question?
|
||||
|
||||
- A: Correct
|
||||
- B: Wrong
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
assert result in ['imported', 'updated']
|
||||
assert stats.questions_with_answers == 1
|
||||
|
||||
# Verify in database
|
||||
question = Question.objects.get(text='Test question?')
|
||||
assert question.correct_answer == 'A'
|
||||
assert question.options.count() == 2
|
||||
|
||||
def test_mtime_tracking(self, tmp_path):
|
||||
"""Test that file modification time is tracked"""
|
||||
question_file = tmp_path / "question4.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What is the correct answer?
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
question = Question.objects.get(text='What is the correct answer?')
|
||||
assert question.file_mtime == question_file.stat().st_mtime
|
||||
|
||||
def test_update_existing_question(self, tmp_path):
|
||||
"""Test updating an existing question"""
|
||||
question_file = tmp_path / "question5.md"
|
||||
|
||||
# Initial import
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question to update?
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
import_question_file(question_file, tmp_path, ImportStats(), force=True)
|
||||
|
||||
# Update the file
|
||||
time.sleep(0.1)
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question to update?
|
||||
```spoiler-block:
|
||||
B
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=False)
|
||||
|
||||
assert result == 'updated'
|
||||
assert Question.objects.get(text='Question to update?').correct_answer == 'B'
|
||||
187
quiz/quiz/utils/tests/test_unified_parser.py
Normal file
187
quiz/quiz/utils/tests/test_unified_parser.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import datetime
|
||||
from quiz.utils.unified_parser import UnifiedParser, QuestionType
|
||||
|
||||
def test_parse_mcq_question():
|
||||
content = """---
|
||||
tags: [frågetyp/mcq, ah2]
|
||||
date: 2024-03-21
|
||||
---
|
||||
Question?
|
||||
- A: Yes
|
||||
- B: No
|
||||
- C: Maybe
|
||||
- D: Never
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.MCQ
|
||||
assert data.question == "Question?"
|
||||
assert data.answer == ["A", "D"]
|
||||
assert data.num_questions == 1
|
||||
assert data.is_complete is True
|
||||
assert data.options == ["A: Yes", "B: No", "C: Maybe", "D: Never"]
|
||||
assert data.metadata == {"tags": ["frågetyp/mcq", "ah2"], "date": datetime.date(2024, 3, 21)}
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_scq_question():
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Pick one:
|
||||
- A: One
|
||||
- B: Two
|
||||
```spoiler-block:
|
||||
B
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SCQ
|
||||
assert data.question == "Pick one:"
|
||||
assert data.answer == "B"
|
||||
assert data.num_questions == 1
|
||||
assert data.is_complete is True
|
||||
assert data.options == ["A: One", "B: Two"]
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_textfält_question():
|
||||
content = """---
|
||||
tags: [frågetyp/textfält]
|
||||
---
|
||||
Name these:
|
||||
a) Part 1
|
||||
b) Part 2
|
||||
```spoiler-block:
|
||||
a) Left
|
||||
b) Right
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.TEXTFÄLT
|
||||
assert data.question == "Name these:"
|
||||
assert data.answer == ["a) Left", "b) Right"]
|
||||
assert data.num_questions == 2
|
||||
assert len(data.sub_questions) == 2
|
||||
assert data.sub_questions[0].id == "a"
|
||||
assert data.sub_questions[0].text == "Part 1"
|
||||
assert data.sub_questions[0].answer == "a) Left"
|
||||
assert data.sub_questions[0].options is None
|
||||
|
||||
def test_parse_matching_question():
|
||||
content = """---
|
||||
tags: [frågetyp/matching]
|
||||
---
|
||||
Match:
|
||||
- 1
|
||||
- 2
|
||||
- A
|
||||
- B
|
||||
```spoiler-block:
|
||||
1: A
|
||||
2: B
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.MATCHING
|
||||
assert data.question == "Match:"
|
||||
assert data.answer == [["1", "A"], ["2", "B"]]
|
||||
assert data.num_questions == 1
|
||||
assert data.options == ["1", "2", "A", "B"]
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_question_with_image_and_instruction():
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
**Välj ett alternativ:**
|
||||
|
||||
![[brain.png|300]]
|
||||
|
||||
What is this?
|
||||
- A: Brain
|
||||
- B: Heart
|
||||
```spoiler-block:
|
||||
A
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SCQ
|
||||
assert data.question == "What is this?"
|
||||
assert data.instruction == "Välj ett alternativ:"
|
||||
assert data.image == "![[brain.png]]"
|
||||
assert data.is_complete is True
|
||||
|
||||
def test_parse_field_question_with_ranges():
|
||||
content = """---
|
||||
tags: [frågetyp/sifferfält]
|
||||
---
|
||||
Identify the structures:
|
||||
|
||||
a) Arachnoidea? (1..10)
|
||||
(0.5 p)
|
||||
b) Cortex cerebri (1..10)
|
||||
(0.5 p)
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 3
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SIFFERFÄLT
|
||||
assert data.num_questions == 2
|
||||
assert len(data.sub_questions) == 2
|
||||
|
||||
# Part A
|
||||
assert data.sub_questions[0].id == "a"
|
||||
assert data.sub_questions[0].text == "Arachnoidea?"
|
||||
assert data.sub_questions[0].options == [str(x) for x in range(1, 11)]
|
||||
assert data.sub_questions[0].answer == "a) 7"
|
||||
|
||||
# Part B
|
||||
assert data.sub_questions[1].id == "b"
|
||||
assert data.sub_questions[1].text == "Cortex cerebri"
|
||||
assert data.sub_questions[1].options == [str(x) for x in range(1, 11)]
|
||||
assert data.sub_questions[1].answer == "b) 3"
|
||||
|
||||
def test_parse_field_question_with_list_options():
|
||||
content = """---
|
||||
tags: [frågetyp/sifferfält]
|
||||
---
|
||||
a) First (A, B, C)
|
||||
b) Second (1, 2, 3)
|
||||
|
||||
```spoiler-block:
|
||||
a) A
|
||||
b) 2
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.sub_questions[0].options == ["A", "B", "C"]
|
||||
assert data.sub_questions[1].options == ["1", "2", "3"]
|
||||
|
||||
def test_parse_hotspot_question():
|
||||
content = """---
|
||||
tags: [frågetyp/hotspot]
|
||||
---
|
||||
Klicka på hippocampus!
|
||||
|
||||
```spoiler-block:
|
||||
![[brain_atlas.png]]
|
||||
Det här är hippocampus.
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.HOTSPOT
|
||||
assert data.answer == "Det här är hippocampus."
|
||||
assert data.answer_image == "![[brain_atlas.png]]"
|
||||
assert data.is_complete is True
|
||||
|
||||
def test_completeness_missing_sub_questions():
|
||||
content = """---
|
||||
tags: [frågetyp/textfält]
|
||||
---
|
||||
a) one
|
||||
b) two
|
||||
```spoiler-block:
|
||||
a) found
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.num_questions == 2
|
||||
assert data.is_complete is False
|
||||
assert len(data.sub_questions) == 2
|
||||
assert data.sub_questions[0].answer == "a) found"
|
||||
assert data.sub_questions[1].answer is None
|
||||
465
quiz/quiz/utils/unified_parser.py
Normal file
465
quiz/quiz/utils/unified_parser.py
Normal file
@@ -0,0 +1,465 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from quiz.utils.question_parser import Node, parse_question_from_content
|
||||
|
||||
# === REGEX PATTERNS ===
|
||||
|
||||
# Matches Obsidian-style embeds like ![[image.png]] or ![[image.png|300]]
|
||||
EMBED_RE = re.compile(
|
||||
r"!\[\[" # Start of embed
|
||||
r".*?" # Content (filename and optional pipes)
|
||||
r"\]\]" # End of embed
|
||||
)
|
||||
|
||||
# Captures the filename from an Obsidian embed, ignoring dimensions
|
||||
IMAGE_RE = re.compile(
|
||||
r"!\[\[" # Start of embed
|
||||
r"([^|\]]+)" # Group 1: Filename (everything before | or ])
|
||||
r"(?:\|.*?)?" # Optional dimension part starting with |
|
||||
r"\]\]" # End of embed
|
||||
)
|
||||
|
||||
# Matches lettered options at the start of a line, e.g., "A: Text" or "B. Text"
|
||||
OPTION_LETTER_RE = re.compile(
|
||||
r"^([A-Z])" # Group 1: Single uppercase letter at start
|
||||
r"[:\.]?" # Optional colon or period
|
||||
r"\s*" # Optional whitespace
|
||||
r"(.*)$" # Group 2: The rest of the text
|
||||
)
|
||||
|
||||
# Matches standalone uppercase letters used for answers, e.g., "A", "A och B"
|
||||
ANSWER_LETTER_RE = re.compile(
|
||||
r"\b" # Word boundary
|
||||
r"([A-Z])" # Group 1: Single uppercase letter
|
||||
r"\b" # Word boundary
|
||||
)
|
||||
|
||||
# Matches sub-question markers like a), b) at the start of a line
|
||||
SUB_QUESTION_LETTER_RE = re.compile(
|
||||
r"^\s*" # Start of line and optional whitespace
|
||||
r"([a-z])" # Group 1: Single lowercase letter
|
||||
r"\)" # Closing parenthesis
|
||||
, re.MULTILINE)
|
||||
|
||||
# Matches numbered sub-question markers like 1), 2) at the start of a line
|
||||
SUB_QUESTION_NUMBER_RE = re.compile(
|
||||
r"^\s*" # Start of line and optional whitespace
|
||||
r"(\d+)" # Group 1: One or more digits
|
||||
r"\)" # Closing parenthesis
|
||||
, re.MULTILINE)
|
||||
|
||||
# Matches select range patterns like (1..10)
|
||||
SELECT_RANGE_RE = re.compile(
|
||||
r"\(" # Opening parenthesis
|
||||
r"(\d+)" # Group 1: Start number
|
||||
r"\.\." # Range dots
|
||||
r"(\d+)" # Group 2: End number
|
||||
r"\)" # Closing parenthesis
|
||||
)
|
||||
|
||||
# Matches letter range patterns like (A..H)
|
||||
SELECT_LETTER_RANGE_RE = re.compile(
|
||||
r"\(" # Opening parenthesis
|
||||
r"([A-Z])" # Group 1: Start letter
|
||||
r"\.\." # Range dots
|
||||
r"([A-Z])" # Group 2: End letter
|
||||
r"\)" # Closing parenthesis
|
||||
)
|
||||
|
||||
# Matches select list patterns like (A, B, C)
|
||||
SELECT_LIST_RE = re.compile(
|
||||
r"\(" # Opening parenthesis
|
||||
r"(" # Group 1: The list content
|
||||
r"[^)]+" # Anything but closing parenthesis
|
||||
r"," # At least one comma
|
||||
r"[^)]+" # Anything but closing parenthesis
|
||||
r")"
|
||||
r"\)" # Closing parenthesis
|
||||
)
|
||||
|
||||
# Matches sub-question markers in mid-text (used for splitting intro text)
|
||||
FIELD_MARKER_RE = re.compile(
|
||||
r"\b" # Word boundary
|
||||
r"([a-z]|\d+)" # Group 1: Letter or digit
|
||||
r"\)" # Closing parenthesis
|
||||
)
|
||||
|
||||
# Matches sub-question markers (a, b or 1, 2) at start of line for splitting
|
||||
SUB_QUESTION_SPLIT_RE = re.compile(
|
||||
r"^\s*" # Start of line and optional whitespace
|
||||
r"([a-z]|\d+)" # Group 1: Single letter or one or more digits
|
||||
r"\)" # Closing parenthesis
|
||||
r"\s*" # Optional trailing whitespace
|
||||
, re.MULTILINE)
|
||||
|
||||
# Matches point markers like (0.5 p) or (1 p)
|
||||
POINTS_RE = re.compile(
|
||||
r"\(" # Opening parenthesis
|
||||
r"\d+" # One or more digits
|
||||
r"(?:\.\d+)?" # Optional decimal part
|
||||
r"\s*" # Optional whitespace
|
||||
r"p" # Literal 'p'
|
||||
r"\)" # Closing parenthesis
|
||||
)
|
||||
|
||||
|
||||
class QuestionType(Enum):
|
||||
MCQ = "mcq"
|
||||
SCQ = "scq"
|
||||
MATCHING = "matching"
|
||||
TEXTALTERNATIV = "textalternativ"
|
||||
TEXTFÄLT = "textfält"
|
||||
SIFFERFÄLT = "sifferfält"
|
||||
HOTSPOT = "hotspot"
|
||||
SAMMANSATT = "sammansatt"
|
||||
DND_TEXT = "dnd-text"
|
||||
DND_BILD = "dnd-bild"
|
||||
SANT_FALSKT = "sant-falskt"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubQuestion:
|
||||
id: str # 'a', 'b', etc.
|
||||
text: str # Text for this part
|
||||
answer: Any = None
|
||||
options: list[str] | None = None # None if text input
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionData:
|
||||
type: QuestionType
|
||||
question: str
|
||||
answer: Any # str | list[str] | list[list[str]]
|
||||
num_questions: int = 1 # Total sub-questions (a, b, c...)
|
||||
is_complete: bool = False
|
||||
options: list[str] = field(default_factory=list)
|
||||
image: str | None = None
|
||||
answer_image: str | None = None
|
||||
instruction: str | None = None
|
||||
metadata: dict = field(default_factory=dict)
|
||||
sub_questions: list[SubQuestion] = field(default_factory=list)
|
||||
|
||||
|
||||
class UnifiedParser:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
self.parsed = parse_question_from_content(content)
|
||||
self.metadata = self.parsed.metadata
|
||||
self.nodes = self.parsed.nodes
|
||||
|
||||
# Pre-extract common fields
|
||||
self.type = self._extract_type()
|
||||
self.question = self._extract_question_text()
|
||||
self.instruction = self._extract_instruction()
|
||||
self.image = self._extract_image()
|
||||
self.num_questions = self._count_sub_questions()
|
||||
|
||||
def parse(self) -> QuestionData:
|
||||
match self.type:
|
||||
case QuestionType.MCQ | QuestionType.SCQ:
|
||||
data = self._parse_choice_question()
|
||||
case QuestionType.MATCHING:
|
||||
data = self._create_question(
|
||||
answer=self._extract_answer_pairs(),
|
||||
options=self._extract_bullet_list_options()
|
||||
)
|
||||
case QuestionType.TEXTALTERNATIV:
|
||||
data = self._create_question(
|
||||
answer=self._extract_raw_answer(),
|
||||
options=self._extract_bullet_list_options()
|
||||
)
|
||||
case QuestionType.TEXTFÄLT:
|
||||
data = self._parse_text_field()
|
||||
case QuestionType.SIFFERFÄLT:
|
||||
data = self._create_question(answer=self._extract_raw_answer())
|
||||
case QuestionType.HOTSPOT:
|
||||
data = self._parse_hotspot()
|
||||
case QuestionType.SAMMANSATT:
|
||||
data = self._create_question(answer=self._extract_answer_lines())
|
||||
case QuestionType.DND_TEXT:
|
||||
data = self._create_question(answer=self._extract_answer_lines())
|
||||
case QuestionType.DND_BILD:
|
||||
data = self._create_question(answer=self._extract_answer_lines())
|
||||
case QuestionType.SANT_FALSKT:
|
||||
data = self._create_question(answer=self._extract_answer_pairs())
|
||||
case _:
|
||||
raise ValueError(f"Unsupported question type: {self.type}")
|
||||
|
||||
data.num_questions = self.num_questions
|
||||
data.sub_questions = self._extract_sub_questions(data)
|
||||
data.is_complete = self._check_completeness(data)
|
||||
return data
|
||||
|
||||
def _check_completeness(self, data: QuestionData) -> bool:
|
||||
"""Verify if the answer is complete (no TODOs, matches sub-question count)."""
|
||||
content = self._extract_raw_answer()
|
||||
if not content or "TODO" in content:
|
||||
return False
|
||||
|
||||
# If we have sub-questions, ensure we have enough answer lines/parts
|
||||
if data.num_questions > 1:
|
||||
if isinstance(data.answer, list):
|
||||
if data.type in [QuestionType.MCQ, QuestionType.SCQ]:
|
||||
return len(data.answer) > 0
|
||||
return len(data.answer) >= data.num_questions
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _count_sub_questions(self) -> int:
|
||||
"""Count sub-questions like a), b), c) or 1), 2) in the question text."""
|
||||
md_content = self.parsed.raw_content
|
||||
|
||||
# Count lettered sub-questions: a), b), c)...
|
||||
letters = SUB_QUESTION_LETTER_RE.findall(md_content)
|
||||
if letters:
|
||||
unique_letters = sorted(list(set(letters)))
|
||||
if "a" in unique_letters:
|
||||
max_letter = max(unique_letters)
|
||||
return ord(max_letter) - ord("a") + 1
|
||||
|
||||
# Count numbered sub-questions: 1), 2), 3)...
|
||||
numbers = SUB_QUESTION_NUMBER_RE.findall(md_content)
|
||||
if numbers:
|
||||
unique_numbers = sorted(list(set(map(int, numbers))))
|
||||
if 1 in unique_numbers:
|
||||
return max(unique_numbers)
|
||||
|
||||
return 1
|
||||
|
||||
def _create_question(
|
||||
self,
|
||||
answer: Any,
|
||||
options: list[str] = None,
|
||||
answer_image: str | None = None
|
||||
) -> QuestionData:
|
||||
"""Create a QuestionData object with common fields pre-populated."""
|
||||
return QuestionData(
|
||||
type=self.type,
|
||||
question=self.question,
|
||||
answer=answer,
|
||||
options=options or [],
|
||||
image=self.image,
|
||||
answer_image=answer_image,
|
||||
instruction=self.instruction,
|
||||
metadata=self.metadata
|
||||
)
|
||||
|
||||
# === Extraction Helpers ===
|
||||
|
||||
def _extract_type(self) -> QuestionType:
|
||||
tags = self.metadata.get("tags", [])
|
||||
for tag in tags:
|
||||
if tag.startswith("frågetyp/"):
|
||||
type_str = tag.split("/", 1)[1]
|
||||
try:
|
||||
return QuestionType(type_str)
|
||||
except ValueError:
|
||||
continue
|
||||
return QuestionType.MCQ # Default
|
||||
|
||||
def _extract_question_text(self) -> str:
|
||||
texts = []
|
||||
for node in self.nodes:
|
||||
if node.type == "paragraph":
|
||||
text = node.text.strip()
|
||||
# Skip instructions
|
||||
if text.startswith("Välj") and "alternativ" in text:
|
||||
continue
|
||||
|
||||
# If paragraph contains a sub-question marker, stop there
|
||||
# We use a more liberal search here because mistune might have joined lines
|
||||
first_marker = FIELD_MARKER_RE.search(text)
|
||||
|
||||
if first_marker:
|
||||
text = text[:first_marker.start()].strip()
|
||||
if text:
|
||||
# Only add if it doesn't look like an instruction we already skipped
|
||||
if not (text.startswith("Välj") and "alternativ" in text):
|
||||
texts.append(text)
|
||||
break # Stop collecting intro text once we hit a sub-question
|
||||
|
||||
# Clean and collect
|
||||
text = EMBED_RE.sub("", text).strip()
|
||||
text = text.replace("**", "")
|
||||
if text:
|
||||
texts.append(text)
|
||||
return "\n".join(texts)
|
||||
|
||||
def _extract_instruction(self) -> str | None:
|
||||
for node in self.nodes:
|
||||
if node.type == "paragraph":
|
||||
text = node.text.strip()
|
||||
if "Välj" in text and "alternativ" in text:
|
||||
return text.replace("**", "")
|
||||
return None
|
||||
|
||||
def _extract_image(self) -> str | None:
|
||||
for node in self.nodes:
|
||||
# Check for direct embed nodes
|
||||
if node.type == "embed":
|
||||
return f"![[{node.attrs['filename']}]]"
|
||||
|
||||
# Check inside paragraphs/lists for inline embeds
|
||||
if node.type in ["paragraph", "list"]:
|
||||
for child in node.children:
|
||||
if child.type == "embed":
|
||||
return f"![[{child.attrs['filename']}]]"
|
||||
|
||||
if node.raw:
|
||||
match = IMAGE_RE.search(node.raw)
|
||||
if match:
|
||||
return f"![[{match.group(1)}]]"
|
||||
return None
|
||||
|
||||
def _extract_sub_questions(self, data: QuestionData) -> list[SubQuestion]:
|
||||
# Only split the text BEFORE the spoiler block to avoid misidentifying markers in answers
|
||||
full_raw = self.parsed.raw_content
|
||||
parts = full_raw.split("```", 1)
|
||||
question_portion = parts[0]
|
||||
|
||||
# Split by sub-question markers at the start of lines: a), b) or 1), 2)
|
||||
segments = SUB_QUESTION_SPLIT_RE.split(question_portion)[1:]
|
||||
|
||||
sub_questions = []
|
||||
# segments will be [id1, text1, id2, text2, ...]
|
||||
for i in range(0, len(segments), 2):
|
||||
q_id = segments[i]
|
||||
q_full_text = segments[i+1].strip()
|
||||
|
||||
# Extract options if any (for select fields)
|
||||
options = self._extract_select_options(q_full_text)
|
||||
|
||||
# Clean text (remove point markers like (0.5 p) and select patterns)
|
||||
clean_text = SELECT_RANGE_RE.sub("", q_full_text)
|
||||
clean_text = SELECT_LETTER_RANGE_RE.sub("", clean_text)
|
||||
clean_text = SELECT_LIST_RE.sub("", clean_text)
|
||||
clean_text = POINTS_RE.sub("", clean_text).strip()
|
||||
|
||||
# Extract answer for this part
|
||||
answer = None
|
||||
if isinstance(data.answer, list) and i//2 < len(data.answer):
|
||||
answer = data.answer[i//2]
|
||||
elif isinstance(data.answer, str):
|
||||
lines = [l.strip() for l in data.answer.split("\n") if l.strip()]
|
||||
if i//2 < len(lines):
|
||||
answer = lines[i//2]
|
||||
elif data.num_questions == 1:
|
||||
answer = data.answer
|
||||
|
||||
sub_questions.append(SubQuestion(
|
||||
id=q_id,
|
||||
text=clean_text,
|
||||
answer=answer,
|
||||
options=options
|
||||
))
|
||||
|
||||
return sub_questions
|
||||
|
||||
def _extract_select_options(self, text: str) -> list[str] | None:
|
||||
"""Extract options from patterns like (1..10), (A..D), or (A, B, C)."""
|
||||
# Numerical range (1..10)
|
||||
match = SELECT_RANGE_RE.search(text)
|
||||
if match:
|
||||
start, end = map(int, match.groups())
|
||||
return [str(x) for x in range(start, end + 1)]
|
||||
|
||||
# Letter range (A..H)
|
||||
match = SELECT_LETTER_RANGE_RE.search(text)
|
||||
if match:
|
||||
start, end = match.groups()
|
||||
return [chr(x) for x in range(ord(start), ord(end) + 1)]
|
||||
|
||||
# Comma-separated list (A, B, C)
|
||||
match = SELECT_LIST_RE.search(text)
|
||||
if match:
|
||||
items = match.group(1).split(",")
|
||||
return [item.strip() for item in items]
|
||||
|
||||
return None
|
||||
|
||||
def _extract_lettered_options(self) -> list[str]:
|
||||
options = []
|
||||
for node in self.nodes:
|
||||
if node.type == "list":
|
||||
for item in node.children:
|
||||
item_text = item.text.strip()
|
||||
if OPTION_LETTER_RE.match(item_text):
|
||||
options.append(item_text)
|
||||
return options
|
||||
|
||||
def _extract_bullet_list_options(self) -> list[str]:
|
||||
options = []
|
||||
for node in self.nodes:
|
||||
if node.type == "list":
|
||||
for item in node.children:
|
||||
options.append(item.text.strip())
|
||||
return options
|
||||
|
||||
def _extract_raw_answer(self) -> str:
|
||||
for node in self.nodes:
|
||||
if node.type == "block_code" and node.attrs.get("info") == "spoiler-block:":
|
||||
return node.raw.strip()
|
||||
return ""
|
||||
|
||||
def _extract_answer_letters(self) -> list[str]:
|
||||
content = self._extract_raw_answer()
|
||||
if not content or content == "TODO":
|
||||
return []
|
||||
return ANSWER_LETTER_RE.findall(content)
|
||||
|
||||
def _extract_answer_lines(self) -> list[str]:
|
||||
content = self._extract_raw_answer()
|
||||
if not content or content == "TODO":
|
||||
return []
|
||||
return [line.strip() for line in content.split("\n") if line.strip()]
|
||||
|
||||
def _extract_answer_pairs(self) -> list[list[str]]:
|
||||
lines = self._extract_answer_lines()
|
||||
pairs = []
|
||||
for line in lines:
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
pairs.append([key.strip(), value.strip()])
|
||||
return pairs
|
||||
|
||||
# === Question Type Handlers ===
|
||||
|
||||
def _parse_choice_question(self) -> QuestionData:
|
||||
answer_letters = self._extract_answer_letters()
|
||||
if self.type == QuestionType.MCQ:
|
||||
answer = answer_letters
|
||||
else:
|
||||
answer = answer_letters[0] if answer_letters else ""
|
||||
|
||||
return self._create_question(
|
||||
answer=answer,
|
||||
options=self._extract_lettered_options()
|
||||
)
|
||||
|
||||
def _parse_text_field(self) -> QuestionData:
|
||||
lines = self._extract_answer_lines()
|
||||
return self._create_question(
|
||||
answer=lines if len(lines) > 1 else (lines[0] if lines else "")
|
||||
)
|
||||
|
||||
|
||||
def _parse_hotspot(self) -> QuestionData:
|
||||
content = self._extract_raw_answer()
|
||||
answer_image = None
|
||||
|
||||
match = IMAGE_RE.search(content)
|
||||
if match:
|
||||
answer_image = f"![[{match.group(1)}]]"
|
||||
answer_text = EMBED_RE.sub("", content).strip()
|
||||
else:
|
||||
answer_text = content
|
||||
|
||||
return self._create_question(
|
||||
answer=answer_text,
|
||||
answer_image=answer_image
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
# This makes tests a package
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats
|
||||
from quiz.models import Question, Option
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestMarkdownParsing:
|
||||
"""Test parsing of various Obsidian markdown question formats"""
|
||||
|
||||
def test_parse_single_choice_question(self):
|
||||
"""Test parsing standard single choice question (SCQ)"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/scq, anatomi]
|
||||
date: 2022-01-15
|
||||
---
|
||||
What is the correct answer?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Wrong answer
|
||||
- B: Correct answer
|
||||
- C: Another wrong
|
||||
|
||||
```spoiler-block:
|
||||
B
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['text'] == 'What is the correct answer?'
|
||||
assert data['correct_answer'] == 'B'
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'scq'
|
||||
assert len(data['options']) == 3
|
||||
assert data['options'][0] == ('A', 'Wrong answer')
|
||||
assert data['options'][1] == ('B', 'Correct answer')
|
||||
|
||||
def test_parse_multiple_choice_question(self):
|
||||
"""Test parsing multiple choice question (MCQ) with 'och' separator"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Vilka av följande räknas till storhjärnans basala kärnor?
|
||||
|
||||
**Välj två alternativ**
|
||||
- A: Putamen
|
||||
- B: Nucleus Ruber
|
||||
- C: Substantia nigra
|
||||
- D: Nucleus caudatus
|
||||
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert 'Vilka av följande' in data['text']
|
||||
assert data['correct_answer'] == 'A,D' # Normalized to comma-separated
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'mcq'
|
||||
assert len(data['options']) == 4
|
||||
|
||||
def test_parse_multiple_choice_comma_separated(self):
|
||||
"""Test MCQ with comma-separated answer"""
|
||||
content = """---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Select two options:
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
- C: Option C
|
||||
- D: Option D
|
||||
|
||||
```spoiler-block:
|
||||
B, C
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert data['correct_answer'] == 'B,C'
|
||||
assert data['has_answer'] is True
|
||||
|
||||
def test_parse_options_without_colon(self):
|
||||
"""Test parsing options in format '- A' without text"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Which letter?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A
|
||||
- B
|
||||
- C
|
||||
- D
|
||||
|
||||
```spoiler-block:
|
||||
C
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert len(data['options']) == 4
|
||||
assert all(text == '' for _, text in data['options'])
|
||||
assert data['correct_answer'] == 'C'
|
||||
|
||||
def test_parse_textalternativ_question(self):
|
||||
"""Test text alternative question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textalternativ, öga, anatomi]
|
||||
---
|
||||
Svara på följande frågor:
|
||||
|
||||
a) Bokstaven B sitter i en lob, vilken?
|
||||
- Lobus temporalis
|
||||
- Lobus frontalis
|
||||
- Lobus parietalis
|
||||
|
||||
b) Vilket funktionellt centra återfinns där?
|
||||
- Syncentrum
|
||||
- Motorcentrum
|
||||
- Somatosensoriskt centrum
|
||||
|
||||
```spoiler-block:
|
||||
a) Lobus parietalis
|
||||
b) Somatosensoriskt centrum
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textalternativ'
|
||||
assert data['has_answer'] is True
|
||||
assert 'Lobus parietalis' in data['correct_answer']
|
||||
assert 'Somatosensoriskt centrum' in data['correct_answer']
|
||||
|
||||
def test_parse_textfalt_question(self):
|
||||
"""Test text field (fill-in) question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textfält, öga]
|
||||
---
|
||||
**Fyll i rätt siffra!**
|
||||
|
||||
a) Vilken siffra pekar på gula fläcken?
|
||||
b) Vilken siffra pekar på choroidea?
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 6
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textfält'
|
||||
assert data['has_answer'] is True
|
||||
assert '7' in data['correct_answer']
|
||||
assert '6' in data['correct_answer']
|
||||
|
||||
def test_skip_todo_answers(self):
|
||||
"""Test that questions with TODO are skipped"""
|
||||
content = """---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
What is this?
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
|
||||
```spoiler-block:
|
||||
TODO
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['has_answer'] is False
|
||||
|
||||
def test_skip_non_question_files(self):
|
||||
"""Test that files without question tags are skipped"""
|
||||
content = """---
|
||||
tags: [ah2, notes, general]
|
||||
---
|
||||
This is just a note, not a question.
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is False
|
||||
|
||||
def test_parse_with_images(self):
|
||||
"""Test parsing questions with embedded images"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq, bild]
|
||||
---
|
||||
![[image.png|338x258]]
|
||||
Vilken bokstav på denna bild sitter på Mesencephalon?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A
|
||||
- B
|
||||
- C
|
||||
- D
|
||||
- E
|
||||
- F
|
||||
|
||||
```spoiler-block:
|
||||
F
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert 'Vilken bokstav' in data['text']
|
||||
assert data['correct_answer'] == 'F'
|
||||
assert len(data['options']) == 6
|
||||
|
||||
def test_parse_yaml_list_format_tags(self):
|
||||
"""Test parsing tags in YAML list format"""
|
||||
content = """---
|
||||
tags:
|
||||
- ah2
|
||||
- provfråga
|
||||
- frågetyp/scq
|
||||
- anatomi
|
||||
date: 2022-01-15
|
||||
---
|
||||
Question text?
|
||||
|
||||
- A: Answer A
|
||||
- B: Answer B
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'scq'
|
||||
|
||||
def test_parse_mixed_option_formats(self):
|
||||
"""Test parsing with inconsistent option formatting"""
|
||||
content = """---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Select correct options:
|
||||
|
||||
**Välj två alternativ:**
|
||||
- A: First option with text
|
||||
- B:Second option no space
|
||||
- C: Third option extra spaces
|
||||
- D:Fourth with trailing
|
||||
|
||||
```spoiler-block:
|
||||
A och C
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert len(data['options']) == 4
|
||||
assert data['options'][0] == ('A', 'First option with text')
|
||||
assert data['options'][1] == ('B', 'Second option no space')
|
||||
assert data['correct_answer'] == 'A,C'
|
||||
|
||||
def test_parse_question_with_multiple_paragraphs(self):
|
||||
"""Test question text extraction with multiple paragraphs"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
This is a longer question that spans multiple lines
|
||||
and has additional context.
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Answer
|
||||
- B: Another
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert 'This is a longer question' in data['text']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestQuestionImport:
|
||||
"""Test actual import of questions to database"""
|
||||
|
||||
def test_import_single_question(self, tmp_path):
|
||||
"""Test importing a single question file"""
|
||||
question_file = tmp_path / "question1.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Test question?
|
||||
|
||||
- A: Correct
|
||||
- B: Wrong
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
assert result in ['imported', 'updated']
|
||||
assert stats.questions_with_answers == 1
|
||||
assert stats.mcq_questions == 1
|
||||
|
||||
# Verify in database
|
||||
question = Question.objects.get(text='Test question?')
|
||||
assert question.correct_answer == 'A'
|
||||
assert question.options.count() == 2
|
||||
|
||||
def test_import_multi_select_question(self, tmp_path):
|
||||
"""Test importing multi-select question"""
|
||||
question_file = tmp_path / "question2.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Multi-select question?
|
||||
|
||||
- A: First correct
|
||||
- B: Wrong
|
||||
- C: Second correct
|
||||
|
||||
```spoiler-block:
|
||||
A och C
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
question = Question.objects.get(text='Multi-select question?')
|
||||
assert question.correct_answer == 'A,C'
|
||||
assert question.options.count() == 3
|
||||
|
||||
def test_skip_question_without_answer(self, tmp_path):
|
||||
"""Test that questions with TODO are not imported"""
|
||||
question_file = tmp_path / "question3.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Incomplete question?
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
|
||||
```spoiler-block:
|
||||
TODO
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
assert result == 'skipped_todo'
|
||||
assert stats.questions_with_todo == 1
|
||||
assert Question.objects.filter(text='Incomplete question?').count() == 0
|
||||
|
||||
def test_mtime_tracking(self, tmp_path):
|
||||
"""Test that file modification time is tracked"""
|
||||
question_file = tmp_path / "question4.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What is the correct answer?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Answer A
|
||||
- B: Answer B
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
# Verify import succeeded
|
||||
assert result in ['imported', 'updated'], f"Import failed with status: {result}"
|
||||
assert stats.created == 1, f"Expected 1 created, got {stats.created}"
|
||||
|
||||
question = Question.objects.get(text='What is the correct answer?')
|
||||
assert question.file_mtime is not None
|
||||
assert question.file_mtime == question_file.stat().st_mtime
|
||||
|
||||
def test_update_existing_question(self, tmp_path):
|
||||
"""Test updating an existing question"""
|
||||
question_file = tmp_path / "question5.md"
|
||||
|
||||
# Initial import
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What is the original question here?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: First answer
|
||||
- B: Second answer
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats1 = ImportStats()
|
||||
result1 = import_question_file(question_file, tmp_path, stats1, force=True)
|
||||
assert result1 in ['imported', 'updated'], f"Initial import failed: {result1}"
|
||||
assert stats1.created == 1
|
||||
|
||||
# Update the file
|
||||
import time
|
||||
time.sleep(0.1) # Ensure mtime changes
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What is the original question here?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: First answer
|
||||
- B: Second answer
|
||||
- C: Third option
|
||||
|
||||
```spoiler-block:
|
||||
C
|
||||
```
|
||||
""")
|
||||
|
||||
stats2 = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats2, force=False)
|
||||
|
||||
assert result == 'updated'
|
||||
assert stats2.updated == 1
|
||||
|
||||
# Verify update
|
||||
question = Question.objects.get(text='What is the original question here?')
|
||||
assert question.correct_answer == 'C'
|
||||
assert question.options.count() == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestImportStatistics:
|
||||
"""Test import statistics tracking"""
|
||||
|
||||
def test_statistics_aggregation(self, tmp_path):
|
||||
"""Test that statistics are correctly aggregated"""
|
||||
# Create multiple question files
|
||||
(tmp_path / "folder1").mkdir()
|
||||
(tmp_path / "folder2").mkdir()
|
||||
|
||||
(tmp_path / "folder1" / "q1.md").write_text("""---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Question number one?
|
||||
|
||||
**Välj två alternativ:**
|
||||
- A: Answer A
|
||||
- B: Answer B
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
(tmp_path / "folder1" / "q2.md").write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question number two?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Answer A
|
||||
```spoiler-block:
|
||||
TODO
|
||||
```
|
||||
""")
|
||||
|
||||
(tmp_path / "folder2" / "q3.md").write_text("""---
|
||||
tags: [notes]
|
||||
---
|
||||
Not a question, just notes
|
||||
""")
|
||||
|
||||
from quiz.utils.importer import import_questions
|
||||
stats = import_questions(tmp_path, tmp_path, force=True)
|
||||
|
||||
assert stats.total_files == 3
|
||||
assert stats.mcq_questions == 2
|
||||
assert stats.questions_with_answers == 1
|
||||
assert stats.questions_with_todo == 1
|
||||
assert stats.non_mcq_skipped == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling"""
|
||||
|
||||
def test_malformed_frontmatter(self):
|
||||
"""Test handling of malformed frontmatter"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
date: broken
|
||||
---
|
||||
Question?
|
||||
- A: Answer
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
# Should still parse as question if tags are recognizable
|
||||
assert is_question is True
|
||||
|
||||
def test_missing_spoiler_block(self):
|
||||
"""Test question without spoiler block"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question without answer?
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['has_answer'] is False
|
||||
|
||||
def test_empty_spoiler_block(self):
|
||||
"""Test question with empty spoiler block"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question with empty answer block?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Option A
|
||||
|
||||
```spoiler-block:
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data.get('has_answer') is False
|
||||
|
||||
def test_special_characters_in_text(self):
|
||||
"""Test handling of special characters"""
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What about "quotes" & <html> tags?
|
||||
|
||||
- A: Option with åäö
|
||||
- B: Option with émojis 🎉
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert '"quotes"' in data['text']
|
||||
assert 'åäö' in data['options'][0][1]
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from quiz.models import Course, Tag, Question, Exam
|
||||
from quiz.forms import CreateQuizForm
|
||||
|
||||
class QuizCreationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.course1 = Course.objects.create(name="Course 1", code="C1")
|
||||
self.course2 = Course.objects.create(name="Course 2", code="C2")
|
||||
|
||||
self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
|
||||
self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
|
||||
|
||||
self.exam1 = Exam.objects.create(course=self.course1, date="2023-01-01")
|
||||
self.exam2 = Exam.objects.create(course=self.course2, date="2023-01-02")
|
||||
|
||||
self.q1 = Question.objects.create(exam=self.exam1, text="Q1", correct_answer="A", file_path="p1")
|
||||
self.q1.tags.add(self.tag1)
|
||||
|
||||
self.q2 = Question.objects.create(exam=self.exam2, text="Q2", correct_answer="A,B", file_path="p2")
|
||||
self.q2.tags.add(self.tag2)
|
||||
|
||||
self.client = Client()
|
||||
|
||||
def test_create_quiz_form_valid(self):
|
||||
form_data = {
|
||||
'course': self.course1.id,
|
||||
'tags': [self.tag1.id],
|
||||
'question_type': ['single']
|
||||
}
|
||||
form = CreateQuizForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_create_quiz_view_post_updates_session(self):
|
||||
response = self.client.post(reverse('create_quiz'), {
|
||||
'course': self.course1.id,
|
||||
'tags': [self.tag1.id],
|
||||
'question_type': ['single']
|
||||
})
|
||||
self.assertRedirects(response, reverse('next_question'))
|
||||
|
||||
session = self.client.session
|
||||
self.assertEqual(session['quiz_filter_course_id'], self.course1.id)
|
||||
self.assertEqual(session['quiz_filter_tag_ids'], [self.tag1.id])
|
||||
self.assertEqual(session['quiz_filter_types'], ['single'])
|
||||
|
||||
def test_get_next_question_respects_filters(self):
|
||||
# Set session data manually
|
||||
session = self.client.session
|
||||
session['quiz_filter_course_id'] = self.course1.id
|
||||
session.save()
|
||||
|
||||
# Should get Q1 (Course 1) but not Q2 (Course 2)
|
||||
# Note: We need a user session for answered questions tracking,
|
||||
# but the view handles anonymous users by creating a session or erroring?
|
||||
# Let's check middleware. It seems middleware handles quiz_user creation.
|
||||
|
||||
response = self.client.get(reverse('next_question'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Q1")
|
||||
|
||||
# Now change filter to Course 2
|
||||
session['quiz_filter_course_id'] = self.course2.id
|
||||
session.save()
|
||||
response = self.client.get(reverse('next_question'))
|
||||
self.assertContains(response, "Q2")
|
||||
|
||||
def test_filter_by_type(self):
|
||||
session = self.client.session
|
||||
session['quiz_filter_types'] = ['multi']
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('next_question'))
|
||||
self.assertContains(response, "Q2") # Q2 is multi (A,B)
|
||||
|
||||
session['quiz_filter_types'] = ['single']
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse('next_question'))
|
||||
self.assertContains(response, "Q1") # Q1 is single (A)
|
||||
260
quiz/uv.lock
generated
260
quiz/uv.lock
generated
@@ -11,6 +11,85 @@ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "coverage"
|
||||
version = "7.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "6.0"
|
||||
@@ -25,23 +104,202 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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/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 = "mistune"
|
||||
version = "3.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[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 = "pygments"
|
||||
version = "2.19.2"
|
||||
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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-subtests"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-frontmatter"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quiz"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "mistune" },
|
||||
{ name = "python-frontmatter" },
|
||||
{ name = "watchdog" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-subtests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=6.0.0" },
|
||||
{ name = "mistune", specifier = ">=3.1.4" },
|
||||
{ name = "python-frontmatter", specifier = ">=1.1.0" },
|
||||
{ name = "watchdog", specifier = ">=6.0.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = []
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.11.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
{ name = "pytest-subtests", specifier = ">=0.15.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
|
||||
Reference in New Issue
Block a user