Skip to content

Courses, Units, Lessons, and KPUB#653

Open
rtibbles wants to merge 4 commits intolearningequality:mainfrom
rtibbles:of_course
Open

Courses, Units, Lessons, and KPUB#653
rtibbles wants to merge 4 commits intolearningequality:mainfrom
rtibbles:of_course

Conversation

@rtibbles
Copy link
Copy Markdown
Member

@rtibbles rtibbles commented Jan 20, 2026

Summary

Adds support for uploading KPUB files
Adds Courses, Units, and Lessons nodes with validation
Adds example courses script for two sample courses.

References

Fixes #638

Reviewer guidance

Copy link
Copy Markdown
Member

@bjester bjester left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to update the le_utils version

@rtibbles
Copy link
Copy Markdown
Member Author

rtibbles commented Feb 8, 2026

Updated!

Comment on lines +276 to +288
"""A file with .kpub extension that isn't a valid zip should fail."""
temp_file = tempfile.NamedTemporaryFile(suffix=".kpub", delete=False)
temp_file.write(b"not a zip file")
temp_file.close()

try:
handler = KPUBConversionHandler()
with pytest.raises(InvalidFileException) as exc_info:
handler.validate_archive(temp_file.name)

assert "zip" in str(exc_info.value).lower()
finally:
os.unlink(temp_file.name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume these are LLM generated?

6 out of 7 tests follow pretty much the same pattern, with this test being the outlier. A test case would eliminate the redundancy.

Although, the approach is somewhat poor. It could use a context manager without delete=False and eliminate the try / finally / unlink. With that change, a test case isn't quite the dramatic cleanup.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - sorry, clearly I forgot to tag this, and I am now remembering that I never came around and did another pass over the code, just pushed the draft once I had the test channels in place.

Will rejig the tests, then double check the implementation.

"""
# Text must be non-empty and not just whitespace
# Pattern from le_utils schema: ^\s*\S[\s\S]*$
if not self.text or not re.match(r"^\s*\S[\s\S]*$", self.text):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this regex would pass with just \t or \n

Comment on lines +1667 to +1671
@questions.setter
def questions(self, value):
"""Setter to allow base Node class initialization (ignored for UnitNode)."""
# UnitNode manages questions through test_questions, so ignore base class assignment
pass
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this is for consistency with ExerciseNode, but they have no shared interface, besides declaring this property.


def test_variants_are_distinct(self):
"""VARIANT_A and VARIANT_B have different values."""
assert VARIANT_A != VARIANT_B
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surely a code review is sufficient for this 😅

assert result.content_node_metadata is not None
assert result.content_node_metadata["kind"] == content_kinds.DOCUMENT
finally:
os.unlink(temp_archive.name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the other kpub tests

@rtibbles rtibbles marked this pull request as ready for review March 20, 2026 22:53
@rtibbles
Copy link
Copy Markdown
Member Author

Have reworked and deduplicated the tests here.

Copy link
Copy Markdown
Member

@bjester bjester left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall everything looks good enough for merge, but I think it'd be worthwhile to match exception messages in TestUnitNodeValidation considering the complexity of the validation. And so for that, at least, I'm requesting changes.

Comment on lines +113 to +114
self._temp_dir = tempfile.TemporaryDirectory(prefix="curriculum_kpub_")
self.temp_dir = self._temp_dir.name
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal since this is an example, but I'm assuming self._temp_dir doesn't need to be set on the class? Looks a little weird.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, not needed at all.


def validate(self):
"""Validate the learning objective against the le_utils schema."""
return bool(self.text and self.text.strip())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UUID generation in the constructor should fail if text is None, so is there use for self.text too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It won't fail if text is an empty or all whitespace string - but I've moved this into init.py to raise earlier.

Comment on lines +1625 to +1628
if not learning_objectives:
raise InvalidNodeException(
"Question must have at least one learning objective"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems add_child and add_question both validate this and both call _validate_learning_objectives. Any reason not to add it there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope - made the error message more generic and moved it.

Comment on lines +1598 to +1600
Args:
node: LessonNode to add
learning_objectives: List of LearningObjective instances
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems ricecooker has inconsistent docstring styles...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they're all over the shop - I think we should just decide what our standard is across our repos and then standardize.

unit = UnitNode(source_id="unit-1", title="Math Unit")
lesson = LessonNode(source_id="lesson-1", title="Lesson 1")
lo = LearningObjective("Understand addition")
unit.add_child(lesson, learning_objectives=[lo])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests here are passing this as a kwarg, which I know is okay, but I think this is considered bad practice?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, updated to just pass positionally.

unit.add_question(make_question("q5"), VARIANT_B, learning_objectives=[lo1])
unit.add_question(make_question("q6"), VARIANT_B, learning_objectives=[lo2])

with pytest.raises(InvalidNodeException):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests look like good coverage, but I think it would provide more certainty that the validation cases are hitting the right spots with some exception message matching for each case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added message matching to all to check this.

rtibbles and others added 3 commits April 7, 2026 18:15
html5lib sets body.text to None when the body element starts with a
child element rather than text. Guard against this the same way as
KPUBConversionHandler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rtibbles
Copy link
Copy Markdown
Member Author

rtibbles commented Apr 8, 2026

Updated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Node classes for Lessons, Units, and Courses

2 participants