Skip to content

Commit 1402d7b

Browse files
authored
Merge pull request #210 from pmelchior/sean/questionnaire_start
Questionnaire UI and initial functionality
2 parents 84f8a8b + baabc65 commit 1402d7b

File tree

10 files changed

+456
-24
lines changed

10 files changed

+456
-24
lines changed

pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ dependencies = [
2626
"optax",
2727
"numpyro",
2828
"h5py",
29+
"ipywidgets",
2930
"pydantic",
3031
"pyyaml",
3132
"colorama",
33+
"markdown",
34+
"pygments",
35+
"jinja2"
3236
]
3337

3438
[project.urls]
@@ -43,6 +47,7 @@ dev = [
4347
"pre-commit", # Used to run checks before finalizing a git commit
4448
"pytest",
4549
"pytest-cov", # Used to report total code coverage
50+
"pytest-mock", # Used to mock objects in tests
4651
"ruff", # Used for static linting of files
4752
]
4853

@@ -56,9 +61,6 @@ build-backend = "setuptools.build_meta"
5661
[tool.setuptools_scm]
5762
write_to = "src/scarlet2/_version.py"
5863

59-
[tool.setuptools.package-data]
60-
"scarlet2.questionnaire" = ["questions.yaml"]
61-
6264
[tool.pytest.ini_options]
6365
testpaths = [
6466
"tests",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .questionnaire import QuestionnaireWidget, run_questionnaire
2+
3+
__all__ = ["QuestionnaireWidget", "run_questionnaire"]

src/scarlet2/questionnaire/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ class Questionnaire(BaseModel):
3434
"""Represents a questionnaire with an initial template and a list of questions."""
3535

3636
initial_template: str
37+
initial_commentary: str = ""
3738
questions: list[Question]
Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,156 @@
1+
import re
12
from importlib.resources import files
23

4+
import jinja2
5+
import markdown
36
import yaml
7+
from IPython.display import display
8+
from ipywidgets import HTML, Button, HBox, Label, Layout, VBox
9+
from pygments import highlight
10+
from pygments.formatters import HtmlFormatter
11+
from pygments.lexers import PythonLexer
412

5-
from scarlet2.questionnaire.models import Questionnaire
13+
from scarlet2.questionnaire.models import Question, Questionnaire, Template
614

7-
FILE_PACKAGE_PATH = "scarlet2.questionnaire"
8-
FILE_NAME = "questions.yaml"
15+
PACKAGE_PATH = "scarlet2.questionnaire"
16+
QUESTIONS_FILE_NAME = "questions.yaml"
17+
18+
VIEWS_PACKAGE_PATH = "scarlet2.questionnaire.views"
19+
OUTPUT_BOX_TEMPLATE_FILE = "output_box.html.jinja"
20+
OUTPUT_BOX_STYLE_FILE = "output_box.css"
21+
22+
23+
QUESTION_BOX_LAYOUT = Layout(
24+
padding="12px",
25+
backgroundColor="#f9f9f9",
26+
border="1px solid #ddd",
27+
borderRadius="10px",
28+
width="45%",
29+
)
30+
31+
OUTPUT_BOX_LAYOUT = Layout(
32+
width="50%",
33+
margin="0 0 0 20px",
34+
)
35+
36+
37+
class QuestionnaireWidget:
38+
"""A widget to run an interactive questionnaire in a Jupyter notebook."""
39+
40+
def __init__(self, questionnaire: Questionnaire):
41+
self.questions = questionnaire.questions
42+
self.code_output = questionnaire.initial_template
43+
self.commentary = questionnaire.initial_commentary
44+
45+
self._init_questions()
46+
self._init_ui()
47+
48+
self._render_output_box()
49+
self._render_next_question()
50+
51+
def _init_questions(self):
52+
self.current_question = None
53+
self.questions_stack = []
54+
self.question_answers = []
55+
self._add_questions_to_stack(self.questions)
56+
57+
def _init_ui(self):
58+
self.output_container = HTML()
59+
self.question_box = VBox([], layout=QUESTION_BOX_LAYOUT)
60+
self.output_box = VBox([self.output_container], layout=OUTPUT_BOX_LAYOUT)
61+
62+
self.ui = HBox([self.question_box, self.output_box])
63+
64+
def _add_questions_to_stack(self, questions: list[Question]):
65+
self.questions_stack = questions + self.questions_stack
66+
67+
def _render_next_question(self):
68+
self.current_question = self.questions_stack.pop(0) if len(self.questions_stack) > 0 else None
69+
self._render_question_box()
70+
71+
def _render_question_box(self):
72+
previous_qs = self._generate_previous_questions()
73+
74+
if self.current_question is None:
75+
self.question_box.children = previous_qs + [Label("🎉 You're done!")]
76+
return
77+
78+
q_label = HTML(f"<b>{self.current_question.question}</b>")
79+
80+
buttons = []
81+
for i, answer in enumerate(self.current_question.answers):
82+
button = Button(
83+
description=answer.answer,
84+
tooltip=answer.tooltip,
85+
layout=Layout(width="auto", margin="4px 0"),
86+
)
87+
88+
def on_click_handler(btn, index=i):
89+
self._handle_answer(index)
90+
91+
button.on_click(on_click_handler)
92+
93+
buttons.append(button)
94+
95+
self.question_box.children = previous_qs + [q_label] + buttons
96+
97+
def _generate_previous_questions(self):
98+
children = []
99+
for q, ans_ind in self.question_answers:
100+
html_str = f"""
101+
<div style="background_color: #111">
102+
<span style="color: #888; padding-right: 10px;">{q.question}</span>
103+
<span style="color: #555;">{q.answers[ans_ind].answer}</span>
104+
</div>
105+
"""
106+
children.append(HTML(html_str))
107+
return children
108+
109+
def _handle_answer(self, answer_index: int):
110+
answer = self.current_question.answers[answer_index]
111+
112+
self._update_template(answer.templates)
113+
self.commentary = answer.commentary
114+
self._render_output_box()
115+
116+
self._add_questions_to_stack(answer.followups)
117+
self.question_answers.append((self.current_question, answer_index))
118+
self._render_next_question()
119+
120+
def _update_template(self, templates: list[Template]):
121+
for t in templates:
122+
pattern = r"\{\{\s*" + re.escape(t.replacement) + r"\s*\}\}"
123+
self.code_output = re.sub(pattern, t.code, self.code_output)
124+
125+
def _render_output_box(self):
126+
output_code = re.sub(r"\{\{\s*\w+\s*\}\}", "", self.code_output)
127+
commentary_html = markdown.markdown(self.commentary, extensions=["extra"])
128+
129+
formatter = HtmlFormatter(style="monokai", noclasses=True)
130+
highlighted_code = highlight(output_code, PythonLexer(), formatter)
131+
132+
# Load Jinja template
133+
template_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_TEMPLATE_FILE)
134+
with template_file.open("r") as f:
135+
template_source = f.read()
136+
template = jinja2.Template(template_source)
137+
138+
# Load additional CSS for styling
139+
css_file = files(VIEWS_PACKAGE_PATH).joinpath(OUTPUT_BOX_STYLE_FILE)
140+
with css_file.open("r") as f:
141+
css_content = f.read()
142+
143+
html_content = f"<style>{css_content}</style>\n" + template.render(
144+
highlighted_code=highlighted_code,
145+
raw_code=output_code,
146+
commentary_html=commentary_html,
147+
)
148+
149+
self.output_container.value = html_content
150+
151+
def show(self):
152+
"""Display the widget in a Jupyter notebook."""
153+
display(self.ui)
9154

10155

11156
def load_questions() -> Questionnaire:
@@ -14,7 +159,22 @@ def load_questions() -> Questionnaire:
14159
Returns:
15160
Questionnaire: The loaded questionnaire model.
16161
"""
17-
questions_path = files(FILE_PACKAGE_PATH).joinpath(FILE_NAME)
162+
questions_path = files(PACKAGE_PATH).joinpath(QUESTIONS_FILE_NAME)
18163
with questions_path.open("r") as f:
19164
raw = yaml.safe_load(f)
20165
return Questionnaire.model_validate(raw)
166+
167+
168+
def run_questionnaire():
169+
"""Run the Scarlet2 initialization questionnaire in a Jupyter notebook.
170+
171+
The questionnaire guides the user through a series of questions to set up
172+
the initialization of a Scarlet2 project that fits their use case.
173+
174+
The user will be presented with questions and multiple-choice answers, and
175+
at the end of the questionnaire, a code snippet that can be used as a
176+
template for initializing Scarlet2 will be generated.
177+
"""
178+
questions = load_questions()
179+
app = QuestionnaireWidget(questions)
180+
app.show()

src/scarlet2/questionnaire/views/__init__.py

Whitespace-only changes.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.code-output {
2+
background-color: #272822;
3+
color: #f1f1f1;
4+
padding: 10px;
5+
border-radius: 10px;
6+
border: 1px solid #444;
7+
font-family: monospace;
8+
overflow-x: auto;
9+
}
10+
11+
.copy_button {
12+
font-size: 12px;
13+
padding: 4px 8px;
14+
background-color: #272822;
15+
color: white;
16+
border: none;
17+
border-radius: 4px;
18+
cursor: pointer;
19+
}
20+
21+
.commentary-box {
22+
background-color: #f6f8fa;
23+
color: #333;
24+
padding: 8px 12px;
25+
border-left: 4px solid #0366d6;
26+
border-radius: 6px;
27+
font-size: 14px;
28+
font-family: sans-serif;
29+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<div class="code-output">
2+
{{highlighted_code | safe}}
3+
<div style="display: flex; justify-content: flex-end; margin-top: 8px;">
4+
<button onclick='navigator.clipboard.writeText({{ raw_code | tojson | safe }})'
5+
class="copy_button">
6+
📋 Copy
7+
</button>
8+
</div>
9+
</div>
10+
<div class="commentary-box">
11+
{{commentary_html | safe}}
12+
</div>

0 commit comments

Comments
 (0)