Skip to content

Add serializer-derived field introspection to ValuesViewset#14327

Open
rtibbles wants to merge 3 commits intolearningequality:developfrom
rtibbles:viewset
Open

Add serializer-derived field introspection to ValuesViewset#14327
rtibbles wants to merge 3 commits intolearningequality:developfrom
rtibbles:viewset

Conversation

@rtibbles
Copy link
Copy Markdown
Member

@rtibbles rtibbles commented Mar 4, 2026

Summary

Enables ValuesViewset to automatically derive values(), field_map, and many=True consolidation from serializer_class — no more manually maintained field tuples that drift out of sync.

  • Serializer introspection module and 58-test suite for the core derivation logic
  • Benchmark script for measuring serialization performance and detecting regressions
  • Migrates FacilityUserViewSet, PublicFacilityUserViewSet, and FacilityUserSignUpViewSet as first validation (all 166 existing auth tests pass unchanged)

References

Partial work towards #14036

Reviewer guidance

  • Start with kolibri/core/utils/serializer_introspection.py — the core derivation logic
  • Then BaseValuesViewset._ensure_initialized in kolibri/core/api.py — where derivation is triggered and cached
  • The auth migration (kolibri/core/auth/api.py) shows the pattern in practice
  • Risky areas: _auto_consolidate (groupby dedup correctness), _field_matches_inferred_type (false negatives cause silent type mismatches)

AI usage

Developed collaboratively with Claude Code (Opus 4.6). Used for initial implementation, test suite, and iterative refinement based on benchmark results and review feedback. All code reviewed and verified against the existing auth test suite.

@github-actions github-actions bot added DEV: backend Python, databases, networking, filesystem... SIZE: very large labels Mar 4, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 4, 2026

@rtibbles rtibbles requested a review from rtibblesbot March 6, 2026 03:43
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

Well-structured first step toward serializer-derived field introspection for ValuesViewset. The core introspection module is solid, the FacilityUser viewset migrations are clean, and the 58-test suite plus benchmark script provide good coverage and regression tooling.

CI passing. No UI changes.

  • suggestion: deferred_fields semantics for non-nested-serializer fields (see inline)
  • suggestion: Thread safety comment precision (see inline)
  • praise: Ordering preservation in _auto_consolidate (see inline)
  • praise: Type-inference optimization in _field_matches_inferred_type (see inline)
  • praise: Removal of FacilityUserConsolidateMixin and its manual re-sort — the new auto-consolidate is more robust

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Reviewed the pull request diff checking for:

  • Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
  • Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
  • Architecture: duplicated concerns, minimal interfaces, composition over inheritance
  • Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
  • Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
  • Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
  • Checked CI status and linked issue acceptance criteria
  • For UI changes: inspected screenshots for layout, visual completeness, and consistency

Comment thread kolibri/core/auth/api.py Outdated
field_map = {
"is_superuser": lambda x: bool(x.pop("devicepermissions__is_superuser")),
}
deferred_fields = ("roles",)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: deferred_fields = ("roles",) here works correctly — the field is skipped during introspection and populated manually in consolidate(). However, roles on PublicFacilityUserSerializer is a ListField(child=CharField()), not a nested ModelSerializer. The deferred_fields docstring and documentation describe it as "nested serializer field names that should be fetched separately," so this usage is semantically imprecise.

This won't cause bugs (the introspection code handles it: ListField fails _is_nested_serializer(), hits the field_name in deferred_fields: continue branch, and gets skipped), but it could confuse future developers who expect deferred_fields entries to be usable with serialize_queryset().

Consider either:

  • Adding a brief comment here explaining that roles is deferred because it's populated manually in consolidate() (not via serialize_queryset()), or
  • Documenting in api_patterns.rst that deferred_fields can also be used for non-nested fields that are populated manually

Comment thread kolibri/core/api.py
Deferred from __init_subclass__ to avoid instantiating serializers
(which may reference querysets) at class definition / import time.

No lock needed under CPython's GIL: the work is idempotent —
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: The thread-safety reasoning is correct in outcome but could be more precise. Under CPython's GIL, two threads can both read _initialized = False and execute the initialization block concurrently (the check-then-act at line 238 is not atomic across bytecodes). The code is safe because the work is idempotent — both threads produce identical results, so the final class state is correct regardless. The waste is only redundant computation, not corruption.

Consider rewording to: "Safe without a lock under CPython's GIL: concurrent threads may redundantly execute this block, but the work is idempotent — identical serializer always produces identical values/field_map, so the final state is correct regardless of execution order."

Comment thread kolibri/core/api.py
self.__class__._pk_getter = pk_getter

joined_many = self._joined_many
original_pk_order = list(dict.fromkeys(pk_getter(item) for item in items))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Capturing original_pk_order before sorting for groupby, then restoring it at line 616, is an elegant solution. This correctly preserves the queryset's ordering (applied by ValuesViewsetOrderingFilter or the default order_by) through the groupby consolidation — a significant improvement over the old FacilityUserConsolidateMixin which had to manually re-sort based on request query params.

current_model = related_model


def _field_matches_inferred_type(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Good design decision to use exact class matching (type(declared_field) is inferred_class) rather than isinstance. This correctly handles the case where a subclass overrides to_representation() — such fields will always go through the transform path, avoiding silent type mismatches. The FK-field handling (lines 92-100) is also well-reasoned: skipping to_representation for PrimaryKeyRelatedField avoids calling it with a raw value instead of a model instance.

return attrs


class FacilityUserRoleSerializer(serializers.ModelSerializer):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Clean separation of FacilityUserRoleSerializer (read-only, excludes user) from the existing RoleSerializer (includes user for write operations). This avoids leaking the parent user ID into nested role objects in API responses.

Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

Solid infrastructure for serializer-derived field introspection. The core introspection module, auto-consolidation, and FacilityUser viewset migrations are well-structured.

CI passing. No UI changes.

Delta from prior review:

  • ACKNOWLEDGED: deferred_fields semantics for non-nested-serializer fields — behavior is correct as-is (ListField in deferred_fields is simply skipped from values(), populated by consolidate())
  • ACKNOWLEDGED: Thread safety comment precision — the comment at _ensure_initialized accurately describes CPython GIL safety and free-threaded Python considerations
  • 3 prior praise finding(s) stand; not re-posted.

New findings:

  • blocking: _build_validation_schema crashes on ListField children in DEBUG mode (see inline)
  • praise: _get_own classmethod pattern (see inline)

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

Comment thread kolibri/core/api.py Outdated
if getattr(field, "write_only", False):
continue
expected_fields.add(field_name)
if hasattr(field, "child"):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: _build_validation_schema crashes in DEBUG mode for serializers with ListField(child=...) fields.

hasattr(field, "child") matches both ListSerializer (intended — its .child is a Serializer with .fields) and ListField (unintended — its .child is a plain Field like CharField, which has no .fields attribute).

Concrete trigger: PublicFacilityUserSerializer.roles is ListField(child=CharField()). When PublicFacilityUserViewSet initializes in DEBUG mode, it calls _build_validation_schema, hits this branch, recurses into CharField, and CharField has no .fieldsAttributeError.

This isn't caught by tests because Django test settings typically have DEBUG=False, and the test suite's @override_settings(DEBUG=True) tests use custom serializers that don't include ListField.

Fix: guard the recursion to only enter serializer children, e.g.:

if hasattr(field, "child") and hasattr(field.child, "fields"):

or more explicitly:

from rest_framework.serializers import BaseSerializer
if hasattr(field, "child") and isinstance(field.child, BaseSerializer):

The same pattern should also be checked in _validate_items_against_schema — if a ListField value (list of primitives like ["admin", "coach"]) reaches the nested validation path, it would try to call .keys() on strings.

Comment thread kolibri/core/api.py
Prevents dynamically cached class attributes (e.g. serializer_class
set by get_serializer_class()) from leaking to child classes.
"""
return cls.__dict__.get(attr, default)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: _get_own via cls.__dict__.get() is a clean solution for preventing dynamically-cached class attributes from leaking through the MRO to child classes — a common and subtle pitfall with Django class-based views and DRF viewsets.

Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

Well-executed serializer-derived field introspection for ValuesViewset. The introspection module is clean, the FacilityUser viewset migrations are correct, and the 58-test suite provides solid coverage.

CI pending. No UI changes.

Delta from prior reviews:

  • RESOLVED: _build_validation_schema crashes on ListField children in DEBUG mode — fixed by adding isinstance(field.child, Serializer) guard at api.py:469, with a dedicated regression test at test_api.py:602
  • 2 prior praise findings stand; not re-posted.

New findings:

  • suggestion: map_fields tuple path silently swallows KeyError for missing source keys (see inline)
  • praise: Comprehensive test for _auto_consolidate ordering preservation (see inline)
  • praise: Clean separation of FacilityUserRoleSerializer from RoleSerializer (see inline)

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

for key, value in field_map.items():
if isinstance(value, tuple):
src, to_repr, default = value
raw = item.pop(src)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: The tuple branch uses item.pop(src) which will raise KeyError if the source key is missing — consistent with the string branch. However, the None check on line 441 means that to_repr is never called for None values, which is good for preventing crashes from fields like PrimaryKeyRelatedField. Just noting that this is a deliberate design choice (confirmed by the TestTransformRenamNoneHandling tests), not an oversight.

self.assertEqual(result[1]["id"], "parent1")
self.assertEqual(result[1]["title"], "Mango")
self.assertEqual(result[2]["id"], "parent2")
self.assertEqual(result[2]["title"], "Apple")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Excellent test — verifying that _auto_consolidate preserves non-PK ordering after its internal sort-by-PK groupby is exactly the kind of behavioral edge case that would be easy to break during future refactors.

return attrs


class FacilityUserRoleSerializer(serializers.ModelSerializer):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Clean extraction of FacilityUserRoleSerializer that excludes the redundant user field for nested use. This makes the serializer self-documenting about what the API actually returns.

viewset = _make_viewset(viewset_class, queryset)
result = viewset.serialize(queryset)

normalized = normalize_for_comparison(result)
Copy link
Copy Markdown
Member

@marcellamaki marcellamaki Apr 7, 2026

Choose a reason for hiding this comment

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

this needs to be removed because the ordering of the output data matters. if we have changed the order, we have meaningfully changed the API (-- so says past Richard)

@rtibbles rtibbles changed the base branch from release-v0.19.x to develop April 9, 2026 21:41
@rtibbles rtibbles marked this pull request as ready for review April 9, 2026 21:42
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

Good cleanup progress — prior blocking and suggestion findings are all resolved.

CI: 1 check still in progress (Build Assets). No failures. No UI changes — Phase 3 skipped.

Prior findings

4 prior findings unchanged (4 resolved, 0 unaddressed).

All prior bot-review findings remain resolved since the last review. The map_fields KeyError suggestion from review 3 is already addressed by the code comment at serializer_introspection.py:482–484 explaining the fail-fast intent; not re-raising.


New findings (following up on marcellamaki's ordering concern):

  • blocking: PublicFacilityUserViewSet lost deterministic output ordering — see inline
  • suggestion: benchmark hash comparison treats ordering changes as WARN rather than FAIL — see inline

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

Comment thread kolibri/core/auth/api.py

class PublicFacilityUserViewSet(ReadOnlyValuesViewset):
queryset = FacilityUser.objects.all()
serializer_class = PublicFacilityUserSerializer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: PublicFacilityUserViewSet no longer returns users in deterministic order.

The removed consolidate() contained items = sorted(items, key=lambda x: x["id"]), which produced id-sorted output as a side effect of preparing items for groupby. The new code has queryset = FacilityUser.objects.all() with no order_by() and no ordering filter backend, so output order is database-defined (nondeterministic).

This is the ordering regression marcellamaki flagged: per project convention, ordering of API output is part of the contract.

Fix: add .order_by("id") to the queryset declaration to restore the prior behavior:

queryset = FacilityUser.objects.all().order_by("id")

c_hash = current["data"]["output_hash"]
data_match = b_hash == c_hash

overall_pass = time_pass and mem_pass and queries_pass
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: data_match is excluded from overall_pass, so a run that produces different output (including different ordering) still reports PASS overall, with only a WARN on the Data output row.

Marcellamaki's comment on this file flagged exactly this: ordering changes are meaningful API changes, so a hash mismatch should not silently pass. Either include data_match in overall_pass, or remove the hash check to avoid giving false assurance when output diverges between baseline and current runs.

Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

No new commits since the last review — both prior findings remain unaddressed.

CI: several checks still in progress (Build Assets, Python tests, Morango Integration Tests), with no failures in completed checks. No UI changes — Phase 3 skipped.

Prior findings

4 prior findings unchanged (4 resolved, 0 acknowledged).

Still unaddressed (re-raised below):

  • PublicFacilityUserViewSet lost deterministic ordering (blocking) — no reply, no code change
  • Benchmark data_match excluded from overall_pass (suggestion) — no reply, no code change

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

Comment thread kolibri/core/auth/api.py

class PublicFacilityUserViewSet(ReadOnlyValuesViewset):
queryset = FacilityUser.objects.all()
serializer_class = PublicFacilityUserSerializer
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking (re-raised, unaddressed): PublicFacilityUserViewSet returns users in nondeterministic (database-defined) order.

The old consolidate() sorted by id as a side effect of groupby preparation. The new queryset = FacilityUser.objects.all() has no order_by(), so output order is nondeterministic. Per project convention, API output ordering is part of the contract.

Fix:

queryset = FacilityUser.objects.all().order_by("id")

c_hash = current["data"]["output_hash"]
data_match = b_hash == c_hash

overall_pass = time_pass and mem_pass and queries_pass
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (re-raised, unaddressed): data_match is excluded from overall_pass, so a run whose output differs from the baseline (including a different ordering) still reports PASS overall, with only a WARN on the Data row.

Given that marcellamaki flagged ordering changes as meaningful API changes, a hash mismatch probably should not silently pass. Either include data_match in overall_pass, or remove the hash check to avoid giving false confidence when output diverges.

rtibbles and others added 3 commits April 9, 2026 18:56
Adds an integration benchmark for comparing ValuesViewset serialization
approaches, measuring performance across synthetic and real-world data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enables ValuesViewset to automatically derive its values() tuple,
field_map, and consolidation logic from the serializer_class, removing
the need for manually maintained field configuration. Includes
serializer introspection utility, comprehensive test suite, and
updated documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes explicit values tuples and field_map dicts from
FacilityUserViewSet, PublicFacilityUserViewSet, and
FacilityUserSignUpViewSet, relying on the serializer to drive
field selection and transformation.

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

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

All prior findings resolved — the two flagged as unaddressed in the previous review were already present in the original commits and are confirmed fixed in the current code.

CI: several checks still in progress (Build APK, Build DMG, Build Pi image, Morango Integration Tests, Python 3.14 unit tests); no failures in completed checks. No UI changes — Phase 3 skipped.

Prior findings

4 prior findings unchanged (4 resolved, 0 acknowledged).

Newly resolved (incorrectly classified as unaddressed in last review):

  • PublicFacilityUserViewSet ordering (blocking) — .order_by("id") confirmed present at kolibri/core/auth/api.py:458 in the original commit 8ac3978
  • Benchmark data_match in overall_pass (suggestion) — data_match confirmed included in overall_pass at integration_testing/scripts/viewset_serialization_benchmark.py:514 in the original commit 12a230a

6/6 prior findings resolved. 0 re-raised.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

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

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants