Search code examples
pythonpython-3.xyamlpyyaml

How to make a dynamic yaml questionnaire with Python?


- 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)

Solution

  • 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.