From 63dfe0eb079e3e89d4abf1496a1abfc180ead3e7 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 14:51:59 +0530 Subject: [PATCH 1/4] feat: forward durationWeeks from course.json to the deploy payload The validate step rewrites the deploy payload from its pydantic Course model, so any field the model does not declare is silently dropped before the create/update request. That blocked the new optional durationWeeks field (an author-declared course length in weeks) that qBraid/qbraid-api now accepts. Declare the field on the model with the same bounds the API enforces (integer, 1 to 52), so a bad value fails the validate step with a clear message instead of a 400 at deploy time. A course.json without the field keeps today's behavior: the key is removed from the payload rather than sent as null, which the API would reject. Co-Authored-By: Claude Fable 5 --- src/scripts/validate_course.py | 19 +++++++++++++++- test/unit/test_validate_course.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index 439eeea..6e0fa20 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -112,6 +112,10 @@ class Course(BaseModel): tags: List[str] content: List[Chapter] deployedTo: List[str] = Field(..., min_length=1) + # Author-declared course length in weeks, forwarded to the qBraid API, + # which enforces the same 1-52 integer range. Optional: courses without + # it keep the platform's chapter-count estimate. + durationWeeks: Optional[int] = Field(None, ge=1, le=52) @field_validator("deployedTo") @classmethod @@ -153,6 +157,19 @@ def check_kernel_references(cls, chapters: List["Chapter"]) -> List["Chapter"]: return chapters +def course_payload(course: Course) -> dict: + """The validated course as the deploy payload dict. + + An optional field left out of course.json must stay absent in the + payload, not become null: the qBraid API accepts a missing + durationWeeks but rejects an explicit null. + """ + payload = course.model_dump(mode="json") + if payload.get("durationWeeks") is None: + payload.pop("durationWeeks", None) + return payload + + class CourseValidator: """Validates the course.json structure and file sizes.""" @@ -195,7 +212,7 @@ def validate(self) -> None: # Save course data for next steps try: with open(Config.COURSE_DATA_FILE_NAME, "w") as f: - json.dump(course.model_dump(mode="json"), f) + json.dump(course_payload(course), f) except IOError as e: logger.error(f"Failed to write {Config.COURSE_DATA_FILE_NAME}: {e}") sys.exit(1) diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index 37f3670..b909808 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -2,11 +2,13 @@ from unittest import mock import pytest +from pydantic import ValidationError from validate_course import ( Course, CourseValidator, _fetch_available_kernels, _format_missing_kernel_error, + course_payload, ) @@ -172,6 +174,41 @@ def test_format_missing_kernel_error_limits_kernel_sample(self): assert message.count("kernel_") <= 10 assert "..." in message + def _duration_course_data(self, **extra): + data = { + "courseName": "Test Course", + "courseDescription": "Desc", + "visibility": "public", + "imageLink": {"darkLogo": "d.png", "lightLogo": "l.png"}, + "tags": ["tag"], + "content": [], + "deployedTo": ["qbraid.com"], + } + data.update(extra) + return data + + def test_duration_weeks_is_forwarded_in_payload(self): + """A declared durationWeeks must survive into the deploy payload.""" + course = Course(**self._duration_course_data(durationWeeks=2)) + + payload = course_payload(course) + + assert payload["durationWeeks"] == 2 + + def test_absent_duration_weeks_stays_absent_in_payload(self): + """No durationWeeks in course.json -> no key in the payload (the API + accepts a missing field but rejects an explicit null).""" + course = Course(**self._duration_course_data()) + + payload = course_payload(course) + + assert "durationWeeks" not in payload + + @pytest.mark.parametrize("bad_value", [0, -3, 53, 2.5, "two"]) + def test_out_of_range_duration_weeks_fails_validation(self, bad_value): + with pytest.raises(ValidationError): + Course(**self._duration_course_data(durationWeeks=bad_value)) + @mock.patch("builtins.open", new_callable=mock.mock_open) @mock.patch("json.load") @mock.patch("validate_course.Path.exists") From 1a899c740b13924fcb7e051a3787693d4648cfda Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 14:58:23 +0530 Subject: [PATCH 2/4] refactor: rely on the API ignoring a null durationWeeks qbraid-api now treats an explicit null the same as an absent field, so the payload no longer needs the key stripped before posting: dump the model as-is and drop the course_payload helper. Co-Authored-By: Claude Fable 5 --- src/scripts/validate_course.py | 19 ++++--------------- test/unit/test_validate_course.py | 13 ++++++------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index 6e0fa20..371d5ad 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -157,19 +157,6 @@ def check_kernel_references(cls, chapters: List["Chapter"]) -> List["Chapter"]: return chapters -def course_payload(course: Course) -> dict: - """The validated course as the deploy payload dict. - - An optional field left out of course.json must stay absent in the - payload, not become null: the qBraid API accepts a missing - durationWeeks but rejects an explicit null. - """ - payload = course.model_dump(mode="json") - if payload.get("durationWeeks") is None: - payload.pop("durationWeeks", None) - return payload - - class CourseValidator: """Validates the course.json structure and file sizes.""" @@ -209,10 +196,12 @@ def validate(self) -> None: logger.info("✅ course.json structure and file sizes are valid") - # Save course data for next steps + # Save course data for next steps. A course.json without + # durationWeeks serializes it as null; the qBraid API treats null + # the same as an absent field. try: with open(Config.COURSE_DATA_FILE_NAME, "w") as f: - json.dump(course_payload(course), f) + json.dump(course.model_dump(mode="json"), f) except IOError as e: logger.error(f"Failed to write {Config.COURSE_DATA_FILE_NAME}: {e}") sys.exit(1) diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index b909808..05d9e4f 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -8,7 +8,6 @@ CourseValidator, _fetch_available_kernels, _format_missing_kernel_error, - course_payload, ) @@ -191,18 +190,18 @@ def test_duration_weeks_is_forwarded_in_payload(self): """A declared durationWeeks must survive into the deploy payload.""" course = Course(**self._duration_course_data(durationWeeks=2)) - payload = course_payload(course) + payload = course.model_dump(mode="json") assert payload["durationWeeks"] == 2 - def test_absent_duration_weeks_stays_absent_in_payload(self): - """No durationWeeks in course.json -> no key in the payload (the API - accepts a missing field but rejects an explicit null).""" + def test_absent_duration_weeks_serializes_as_null(self): + """No durationWeeks in course.json -> null in the payload, which the + qBraid API treats the same as an absent field.""" course = Course(**self._duration_course_data()) - payload = course_payload(course) + payload = course.model_dump(mode="json") - assert "durationWeeks" not in payload + assert payload["durationWeeks"] is None @pytest.mark.parametrize("bad_value", [0, -3, 53, 2.5, "two"]) def test_out_of_range_duration_weeks_fails_validation(self, bad_value): From e30bd7300793a63753a223648dc1717023d78521 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 16 Sep 2026 15:01:28 +0530 Subject: [PATCH 3/4] docs: add durationWeeks forwarding to the unreleased changelog Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf56841..f307091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling +- Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range value now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) - Comprehensive pytest testing infrastructure with unit and E2E tests From 14207620cc7814563ea6eb7bc7cf2ab764f73c7d Mon Sep 17 00:00:00 2001 From: rryoung98 Date: Tue, 22 Sep 2026 00:00:11 -0500 Subject: [PATCH 4/4] fix: refuse a numeric-string durationWeeks instead of coercing it Pydantic's lax mode turned "3" into 3 and forwarded it, where the qBraid API's validator rejects a string with 400. strict=True makes the action fail the same input at validation time with a field-level message, which is what the changelog already promised. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- src/scripts/validate_course.py | 5 +++-- test/unit/test_validate_course.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f307091..69d2919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ uses: qBraid/upload-course-action@v0.1.0-beta ### Added - Unit tests for JSend response handling in course creation and polling -- Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range value now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared +- Forward the optional `durationWeeks` field (author-declared course length in weeks, integer 1-52) from `course.json` to the deploy payload; previously the validate step's model round-trip silently dropped it. An out-of-range or non-integer value (including a numeric string such as `"3"`) now fails validation with a clear message, and an absent field serializes as `null`, which the qBraid API treats as not declared - Unit tests for shared deploy payload/response handling in `deploy_common.py` - Action inputs to configure polling limits (`max-poll-attempts`, `poll-interval-seconds`, `max-consecutive-errors`) - Comprehensive pytest testing infrastructure with unit and E2E tests diff --git a/src/scripts/validate_course.py b/src/scripts/validate_course.py index 371d5ad..f03f336 100644 --- a/src/scripts/validate_course.py +++ b/src/scripts/validate_course.py @@ -114,8 +114,9 @@ class Course(BaseModel): deployedTo: List[str] = Field(..., min_length=1) # Author-declared course length in weeks, forwarded to the qBraid API, # which enforces the same 1-52 integer range. Optional: courses without - # it keep the platform's chapter-count estimate. - durationWeeks: Optional[int] = Field(None, ge=1, le=52) + # it keep the platform's chapter-count estimate. strict, because pydantic + # would otherwise coerce "3" to 3 and forward a value the API rejects. + durationWeeks: Optional[int] = Field(None, ge=1, le=52, strict=True) @field_validator("deployedTo") @classmethod diff --git a/test/unit/test_validate_course.py b/test/unit/test_validate_course.py index 05d9e4f..f83bfc8 100644 --- a/test/unit/test_validate_course.py +++ b/test/unit/test_validate_course.py @@ -203,7 +203,7 @@ def test_absent_duration_weeks_serializes_as_null(self): assert payload["durationWeeks"] is None - @pytest.mark.parametrize("bad_value", [0, -3, 53, 2.5, "two"]) + @pytest.mark.parametrize("bad_value", [0, -3, 53, 2.5, "two", "3", True]) def test_out_of_range_duration_weeks_fails_validation(self, bad_value): with pytest.raises(ValidationError): Course(**self._duration_course_data(durationWeeks=bad_value))