- question: "What is your name?"
type: text
required: true
- question: "How old are you?"
type: number
required: true
min_value: 18
max_value: 100
- question: "What is your gender?"
type: multiple_choice
choices:
- Male
- Female
- Other
required: true
- question: "Do you have any dietary restrictions?"
type: checkboxes
choices:
- Vegetarian
- Vegan
- Gluten-free
- Dairy-free
- None
- question: "Which programming languages do you know?"
type: checkboxes
choices:
- Python
- JavaScript
- Java
- C++
- Ruby
- question: "How satisfied are you with our product?"
type: scale
min_value: 1
max_value: 5
- question: "Any additional comments or feedback?"
type: textarea
This above yaml is a very tiny version of the questionnaire and it goes to almost 70+ questions. I want to design a Class that can read the above yaml and have it be dynamically scalable and readable. The class must also handle data input validation and must give sufficient information for anyone reading it as well. How can I do this?
Edit:
from enum import Enum
import yaml
class QuestionKind(Enum):
TEXT = "text"
NUMBER = "number"
MULTIPLE_CHOICE = "multiple_choice"
CHECKBOXES = "checkboxes"
SCALE = "scale"
TEXTAREA = "textarea"
class Question:
def __init__(self, prompt, kind, choices=None):
self.prompt = prompt
self.kind = kind
self.choices = choices
class Quiz:
def __init__(self, questions_file):
self.questions = self.load_questions(questions_file)
self.current_index = 0
def load_questions(self, questions_file):
with open(questions_file, "r") as file:
data = yaml.safe_load(file)
questions = []
for item in data:
prompt = item["question"]
kind = QuestionKind(item["type"])
choices = item.get("choices")
question = Question(prompt, kind, choices)
questions.append(question)
return questions
def current_question(self):
if self.current_index < len(self.questions):
return self.questions[self.current_index]
else:
return None
def provide_answer(self, answer):
self.current_index += 1
# Process the answer as needed
return self.current_question()
# Usage example
quiz = Quiz("questions.yaml")
current_question = quiz.current_question()
while current_question is not None:
print("Question:", current_question.prompt)
answer = input("Your answer: ")
current_question = quiz.provide_answer(answer)
I was thinking of doing something like this - is this the right way to go?
EDIT : Would this be the improved approach?
import sys
from pathlib import Path
import ruamel.yaml
file_in = Path('questions.yaml')
print(file_in.read_text(), end='===============\n')
class BaseQuestion:
def __init__(self, question, required=False, conditions=None):
self._question = question
self._required = required
self._value = None # to store user response to question
self._conditions = conditions or []
@classmethod
def from_yaml(cls, constructor, node):
kw = ruamel.yaml.CommentedMap()
constructor.construct_mapping(node, kw)
return cls(**kw)
def check_conditions(self, responses):
for condition in self._conditions:
question_id = condition.get('id')
operator = condition.get('operator')
value = condition.get('value')
if question_id in responses and self._compare_values(responses[question_id], operator, value):
return False
return True
def _compare_values(self, value1, operator, value2):
"""Compare two values based on the given operator"""
if operator == "==":
return value1 == value2
elif operator == "!=":
return value1 != value2
elif operator == ">":
return value1 > value2
elif operator == "<":
return value1 < value2
elif operator == ">=":
return value1 >= value2
elif operator == "<=":
return value1 <= value2
else:
raise ValueError(f"Invalid operator: {operator}")
def __repr__(self):
return f'{self.yaml_tag}("{self._question}", required={self._required}, conditions={self._conditions})'
class BaseTextQuestion(BaseQuestion):
yaml_tag = '!Text'
def __init__(self, question, required=False, conditions=None):
super().__init__(question=question, required=required, conditions=conditions)
class TextQuestion(BaseTextQuestion):
yaml_tag = '!Text'
class TextAreaQuestion(BaseTextQuestion):
yaml_tag = '!TextArea'
class NumberQuestion(BaseQuestion):
yaml_tag = '!Number'
def __init__(self, question, min_value, max_value, required=False, conditions=None):
super().__init__(question=question, required=required, conditions=conditions)
self._min = min_value
self._max = max_value
def check(self, responses):
if not self.check_conditions(responses):
return False
if self._required and self._value is None:
return False
if self._value is None:
return True
return self._min <= self._value <= self._max
def __repr__(self):
return f'{self.yaml_tag}("{self._question}", range=[{self._min}, {self._max}], required={self._required}, conditions={self._conditions})'
class ScaleQuestion(NumberQuestion):
yaml_tag = '!Scale'
class ChoiceQuestion(BaseQuestion):
def __init__(self, question, choices, required=False, conditions=None):
super().__init__(question=question, required=required, conditions=conditions)
self._choices = choices
def __repr__(self):
return f'{self.yaml_tag}("{self._question}", choices=[{", ".join(self._choices)}], required={self._required}, conditions={self._conditions})'
class MultipleChoiceQuestion(ChoiceQuestion):
yaml_tag = '!MultipleChoice'
class CheckBoxesQuestion(ChoiceQuestion):
yaml_tag = '!CheckBoxes'
yaml = ruamel.yaml.YAML()
yaml.register_class(TextQuestion)
yaml.register_class(TextAreaQuestion)
yaml.register_class(NumberQuestion)
yaml.register_class(ScaleQuestion)
yaml.register_class(MultipleChoiceQuestion)
yaml.register_class(CheckBoxesQuestion)
questions = yaml.load(file_in)
def display_questions(questions):
"""Display the questions based on the conditions and user responses"""
responses = {}
for question in questions:
if question.check_conditions(responses):
response = input(question._question + " ")
responses[question.__dict__.get("_id")] = response
print("User Responses:", responses)
display_questions(questions)
IMO you should not create a class that interprets the YAML, instead use YAML's facility to tag each of the question
which cause it to load as an appropriate instance of different classes and do away with the type
key.
Those classes need to be registered with the YAML loader, and can be based of a common base class or some class hierarchy, so it can implement common behaviour.
In the following I have the updated YAML in a file called questions.yaml
:
import sys
from pathlib import Path
import ruamel.yaml
file_in = Path('questions.yaml')
print(file_in.read_text(), end='===============\n')
class BaseQuestion:
def __init__(self, question, required=False):
self._question = question
self._required = required
self._value = None # to store user response to question
@classmethod
def from_yaml(cls, constructor, node):
kw = ruamel.yaml.CommentedMap()
constructor.construct_mapping(node, kw)
return cls(**kw)
def __repr__(self):
return(f'{self.yaml_tag}("{self._question}", required={self._required})')
class BaseTextQuestion(BaseQuestion):
yaml_tag = '!Text'
def __init__(self, question, required=False):
super().__init__(question=question, required=required)
class TextQuestion(BaseTextQuestion):
yaml_tag = '!Text'
class TextAreaQuestion(BaseTextQuestion):
yaml_tag = '!TextArea'
class NumberQuestion(BaseQuestion):
yaml_tag = '!Number'
def __init__(self, question, min_value, max_value, required=False):
super().__init__(question=question, required=required)
self._min = min_value
self._max = max_value
def check(self):
"""return False if value not in range or required and not set"""
if self._required and self._value is None:
return False
if self._value is None:
return True
return self._min <= self._value <= self._max
def __repr__(self):
return(f'{self.yaml_tag}("{self._question}", range=[{self._min}, {self._max}], required={self._required})')
class ScaleQuestion(NumberQuestion):
yaml_tag = '!Scale'
class ChoiceQuestion(BaseQuestion):
def __init__(self, question, choices, required=False):
super().__init__(question=question, required=required)
self._choices = choices
def __repr__(self):
return(f'{self.yaml_tag}("{self._question}", choices=[{", ".join(self._choices)}], required={self._required})')
class MultipleChoiceQuestion(ChoiceQuestion):
yaml_tag = '!MultipleChoice'
class CheckBoxesQuestion(ChoiceQuestion):
yaml_tag = '!CheckBoxes'
yaml = ruamel.yaml.YAML()
yaml.register_class(TextQuestion)
yaml.register_class(TextAreaQuestion)
yaml.register_class(NumberQuestion)
yaml.register_class(ScaleQuestion)
yaml.register_class(MultipleChoiceQuestion)
yaml.register_class(CheckBoxesQuestion)
questions = yaml.load(file_in)
for q in questions:
print(q)
which gives:
- !Text
question: "What is your name?"
required: true
- !Number
question: "How old are you?"
required: true
min_value: 18
max_value: 100
- !MultipleChoice
question: "What is your gender?"
choices:
- Male
- Female
- Other
required: true
- !CheckBoxes
question: "Do you have any dietary restrictions?"
choices:
- Vegetarian
- Vegan
- Gluten-free
- Dairy-free
- None
- !CheckBoxes
question: "Which programming languages do you know?"
choices:
- Python
- JavaScript
- Java
- C++
- Ruby
- !Scale
question: "How satisfied are you with our product?"
min_value: 1
max_value: 5
- !TextArea
question: "Any additional comments or feedback?"
===============
!Text("What is your name?", required=True)
!Number("How old are you?", range=[18, 100], required=True)
!MultipleChoice("What is your gender?", choices=[Male, Female, Other], required=True)
!CheckBoxes("Do you have any dietary restrictions?", choices=[Vegetarian, Vegan, Gluten-free, Dairy-free, None], required=False)
!CheckBoxes("Which programming languages do you know?", choices=[Python, JavaScript, Java, C++, Ruby], required=False)
!Scale("How satisfied are you with our product?", range=[1, 5], required=False)
!TextArea("Any additional comments or feedback?", required=False)
Starting with the above you can add methods for displaying each class appropriately for
your application, add check
to other classes than the NumberQuestion
, etc.
You can make a Questions
class that holds the sequence, or even registering it to load, but
in this case that is not what I would do, just working with the list loaded from the root
level YAML sequence.