Source code for jump_starter.questionnaire
import os
import re
from importlib.resources import files
import jinja2
import markdown
import yaml
from IPython.display import display
from ipywidgets import HTML, Button, HBox, Layout, VBox
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import PythonLexer
from .models import (
Question,
QuestionAnswer,
QuestionAnswers,
Questionnaire,
Switch,
Template,
)
[docs]
QUESTION_BOX_LAYOUT = Layout(
padding="12px",
backgroundColor="#f9f9f9",
border="1px solid #ddd",
borderRadius="10px",
width="45%",
)
[docs]
class QuestionnaireWidget:
"""A widget to run an interactive questionnaire in a Jupyter notebook."""
def __init__(
self,
questionnaire: Questionnaire,
save_directory: str | None = None,
initial_answers: QuestionAnswers | None = None,
):
self._load_resources()
self._init_state()
self._init_ui()
if initial_answers is not None:
self._start_with_answers(initial_answers)
else:
self._render_output_box()
self._render_next_question()
[docs]
def _load_resources(self):
question_box_css_file = files(VIEWS_PACKAGE_PATH).joinpath(QUESTION_BOX_STYLE_FILE)
with question_box_css_file.open("r") as f:
self.question_box_css = f.read()
output_box_css_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_STYLE_FILE)
with output_box_css_file.open("r") as f:
self.output_box_css = f.read()
template_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_TEMPLATE_FILE)
with template_file.open("r") as f:
template_source = f.read()
self.output_box_template = jinja2.Template(template_source)
self.question_box_css_html = HTML(f"<style>{self.question_box_css}</style>")
[docs]
def _init_state(self):
self.current_question = None
self.questions_stack = []
self.question_answers = []
self.variables = {}
self.code_output = self.initial_template
self.commentary = self.initial_commentary
self.save_message = None
self._add_questions_to_stack(self.questions)
[docs]
def _init_ui(self):
self.output_container = HTML()
self.question_box = VBox([], layout=QUESTION_BOX_LAYOUT)
# Add a class to the question box for CSS targeting
self.question_box.add_class("question-box-container")
self.output_box = VBox([self.output_container], layout=OUTPUT_BOX_LAYOUT)
self.ui = HBox([self.question_box, self.output_box])
[docs]
def _start_with_answers(self, question_answers: QuestionAnswers):
# First reset the state
self._init_state()
for qa in question_answers.answers:
# Get the next question
self.current_question = self._get_next_question()
# Verify the question matches
if self.current_question is None or self.current_question.question != qa.question:
raise ValueError("Provided answers do not match the question flow.")
if self.current_question.answers[qa.value].answer != qa.answer:
raise ValueError("Provided answers do not match the question flow.")
self._handle_answer(qa.value, render=False)
self._render_output_box()
self._render_next_question()
[docs]
def _add_questions_to_stack(self, questions: list[Question | Switch]):
self.questions_stack = questions + self.questions_stack
[docs]
def _get_next_question(self) -> Question | None:
if len(self.questions_stack) > 0:
question = self.questions_stack.pop(0)
if isinstance(question, Question):
return question
if isinstance(question, Switch):
switch = question
matching_case = None
# Find the default case (where value is None)
for case in switch.cases:
if case.value is None:
matching_case = case
break
variable_value = self.variables.get(switch.switch, None)
if variable_value is not None:
for case in switch.cases:
if case.value == variable_value:
matching_case = case
break
if matching_case is not None:
self._add_questions_to_stack(matching_case.questions)
return self._get_next_question()
return None
[docs]
def _render_next_question(self):
self.current_question = self._get_next_question()
# Reset save message when rendering a new question
self.save_message = None
self._render_question_box()
[docs]
def _render_question_box(self):
previous_qs = self._generate_previous_questions()
# Create save button
save_button = Button(
description="Save Answers",
tooltip="Save all answers to a YAML file",
)
save_button.add_class("save-button")
save_button.on_click(self._save_answers)
save_components = [save_button]
if self.save_message:
message_type = self.save_message["type"]
message_text = self.save_message["text"]
# Set color based on message type
color = "green" if message_type == "success" else "orange"
# Create message HTML
message_html = HTML(f'<div class="save-message" style="color: {color}">{message_text}</div>')
save_components = [message_html, save_button]
self.save_message = None
# Create a container for the save button
save_button_container = VBox(
save_components,
layout=Layout(
width="100%",
padding="0",
),
)
save_button_container.add_class("save-button-container")
if self.current_question is None:
final_message = "<div>🎉 You're done!</div>"
if self.feedback_url:
final_message = (
final_message
+ f"""
<div style="font-size: 0.9em;">
If you encountered any difficulties or have any suggestions,
<a href="{self.feedback_url}" target="_blank"
style="text-decoration: underline; color: #0066cc;">
please fill out our feedback form here.
</a>
</div>
"""
)
# Wrap the final message in a container
final_message_container = VBox([HTML(final_message)], layout=Layout(margin="0 0 0 0"))
self.question_box.children = (
[self.question_box_css_html] + previous_qs + [final_message_container, save_button_container]
)
return
q_label = HTML(f"<b>{self.current_question.question}</b>")
buttons = []
for i, answer in enumerate(self.current_question.answers):
button = Button(
description=answer.answer,
tooltip=answer.tooltip,
layout=Layout(width="auto", margin="4px 0"),
)
def on_click_handler(btn, index=i):
self._handle_answer(index)
button.on_click(on_click_handler)
buttons.append(button)
# Create a container for the buttons
buttons_container = VBox(buttons, layout=Layout(margin="0"))
self.question_box.children = (
[self.question_box_css_html] + previous_qs + [q_label, buttons_container, save_button_container]
)
[docs]
def _generate_previous_questions(self):
items = []
for i, (q, ans_ind) in enumerate(self.question_answers):
ans = q.answers[ans_ind]
# The clickable button styled like text
btn = Button(
description=f"{q.question} — {ans.answer}",
tooltip="Click to go back", # native title tooltip as a fallback
layout=Layout(width="auto", margin="2px 0"),
style={"button_color": "transparent", "font_weight": "normal"},
)
btn.add_class("prev-btn")
qas = self._get_question_answers(up_to_index=i)
def on_click_handler(btn, qas=qas):
self._start_with_answers(qas)
btn.on_click(on_click_handler)
# The custom tooltip node (shown on hover via CSS)
tip_html = HTML("<div class='tooltip'>Click to go back</div>")
# Wrap button + tooltip in a positioned container
container = HBox([btn, tip_html], layout=Layout(position="relative"))
container.add_class("prev-item")
items.append(container)
return items
[docs]
def _handle_answer(self, answer_index: int, render: bool = True):
answer = self.current_question.answers[answer_index]
self._update_template(answer.templates)
self.commentary = answer.commentary
self._add_questions_to_stack(answer.followups)
self.question_answers.append((self.current_question, answer_index))
if self.current_question.variable is not None:
self.variables[self.current_question.variable] = answer_index
if render:
self._render_output_box()
self._render_next_question()
[docs]
def _update_template(self, templates: list[Template]):
for t in templates:
pattern = r"\{\{\s*" + re.escape(t.replacement) + r"\s*\}\}"
self.code_output = re.sub(pattern, t.code, self.code_output)
[docs]
def _render_output_box(self):
output_code = re.sub(r"\{\{\s*\w+\s*\}\}", "", self.code_output)
commentary_html = markdown.markdown(self.commentary, extensions=["extra"])
formatter = HtmlFormatter(style="monokai", noclasses=True)
highlighted_code = highlight(output_code, PythonLexer(), formatter)
# Use pre-loaded template and CSS
html_content = f"<style>{self.output_box_css}</style>\n" + self.output_box_template.render(
highlighted_code=highlighted_code,
raw_code=output_code,
commentary_html=commentary_html,
)
self.output_container.value = html_content
[docs]
def _get_question_answers(self, up_to_index=None) -> QuestionAnswers:
"""Get a QuestionAnswers model containing all answers, optionally up to the specified index.
Args:
up_to_index (int, optional): The index up to which to include answers.
If None, includes all answers. Defaults to None.
Returns:
QuestionAnswers: The collected question answers.
"""
question_answers = QuestionAnswers()
answers_to_process = (
self.question_answers if up_to_index is None else self.question_answers[:up_to_index]
)
for question, answer_index in answers_to_process:
answer = question.answers[answer_index]
question_answer = QuestionAnswer(
question=question.question, answer=answer.answer, value=answer_index
)
question_answers.answers.append(question_answer)
return question_answers
[docs]
def _save_answers(self, _):
"""Save all user answers to a YAML file."""
# Check if there are any answers to save
if not self.question_answers:
# Set warning message
self.save_message = {"type": "warning", "text": "⚠️ No answers to save yet"}
# Re-render the question box to show the message
self._render_question_box()
return None
# Create a QuestionAnswers model
question_answers = self._get_question_answers()
# Create a filename with timestamp to avoid overwriting
timestamp = question_answers.timestamp.strftime("%Y%m%d_%H%M%S")
filename = f"questionnaire_answers_{timestamp}.yaml"
if self.save_directory is not None:
filename = os.path.join(self.save_directory, filename)
# Save to file
with open(filename, "w") as f:
yaml.dump(question_answers.model_dump(), f, default_flow_style=False)
# Set success message
self.save_message = {"type": "success", "text": f"✅ Answers saved to {filename}"}
# Re-render the question box to show the message
self._render_question_box()
return filename